Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,67 @@ object, those changes are reflected on the novem server and anyone watching
the plot in real time.


## Jobs and chains
A job runs the images your repos build. Its chain says what to run: one
image, or several steps where each step's output files feed the next.

```python
from novem import Job, Repo, Chain, Step

fetch = Repo("data_fetcher")
render = Repo("chart_builder")

# a single image
Job("daily_report", chains=fetch)

# a pipeline: dict order is run order, keys are step names
Job("daily_report", chains={
"fetch": fetch,
"render": render.ref("prod"),
})
```

`Repo.ref(label)` picks one of the repo's image labels, such as `latest`,
`prod` or `tag:v1.0.0`. A step can also be a string in the chain grammar
(`"@alice/data_fetcher:prod"`), or another job (`Job("nightly_rollup")` or
`"/j/nightly_rollup"`).

Step names take the grammar's marks: `.name` runs the step once per file the
previous step produced, `!name` stops for a person before the step and `name!`
after it. `Step` adds resources.

```python
Job("monthly_reports", chains={
"split": Repo("splitter"),
".render": Step(render, cpu=2, mem="4G", timeout="5m"),
"collect!": Repo("collector"),
})

# the same thing, built step by step
chain = (
Chain()
.step("split", Repo("splitter"))
.fan("render", render, cpu=2, mem="4G", timeout="5m")
.step("collect", Repo("collector"))
.hold()
)
print(chain) # the text that will be written
job.chains = chain
```

A chain is checked before it is sent, and a malformed one raises
`NovemChainError`. An existing job's chain can be read and edited in place:

```python
print(job.chains) # as stored
job.chains["render"] = render.ref("tag:v2") # rebind one step
job.chains += {"publish": Repo("publisher")} # append a step
del job.chains["publish"]
```

The older `config={"extract": ..., "render": ...}` form still works: the two
names become steps of the chain.


## Error handling
Every novem operation talks to the platform over the network, so any call can
Expand Down
3 changes: 3 additions & 0 deletions novem/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .events import EventMessage, Events
from .group.org import Org
from .job import Job
from .job.chain import Chain, Step
from .profile import Profile
from .repo import Repo
from .session import Session
Expand All @@ -25,6 +26,8 @@
"Computer",
"Image",
"Job",
"Chain",
"Step",
"Claim",
"Profile",
"Events",
Expand Down
17 changes: 17 additions & 0 deletions novem/api_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ class Novem401(NovemException):
pass


class NovemChainError(NovemException):
"""A job chain is malformed, or breaks one of the chain grammar's rules."""

pass


class NovemAuthError(NovemException):
"""No usable credentials could be resolved for a novem connection.

Expand Down Expand Up @@ -189,6 +195,7 @@ def __init__(
config_status, cfg = resolve(default=config_manager or config, **conn)

self._config = cfg
self._config_manager = config_manager or config
self._session = requests.Session()
self._session.headers.update(get_ua(is_cli))
self._session.proxies = urllib.request.getproxies()
Expand Down Expand Up @@ -236,6 +243,16 @@ def _parse_kwargs(self, **_kwargs: Any) -> None:
so editors do not flag the parameter as dead).
"""

def whoami(self) -> str:
"""The username this connection is authenticated as (fetched once per token)."""
token = self.token or ""
cached = self._config_manager.cached_identity(token)
if cached:
return cached
user = self.read("whoami").strip()
self._config_manager.cache_identity(token, user)
return user

def create_token(self, params: Dict[str, str]) -> Dict[str, str]:
r = requests.post(
f"{self._api_root}token",
Expand Down
12 changes: 11 additions & 1 deletion novem/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
from ..api_ref import Novem401, Novem403, Novem404, Novem409, NovemAuthError, NovemException, raise_on_response
from ..api_ref import (
Novem401,
Novem403,
Novem404,
Novem409,
NovemAuthError,
NovemChainError,
NovemException,
raise_on_response,
)

__all__ = [
"NovemException",
Expand All @@ -7,5 +16,6 @@
"Novem401",
"Novem409",
"NovemAuthError",
"NovemChainError",
"raise_on_response",
]
16 changes: 8 additions & 8 deletions novem/group/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
from novem.exceptions import Novem403, Novem404, raise_on_response

from ..api_ref import NovemAPI
from .profile import NovemGroupProfile
from .config import NovemGroupConfig
from .roles import NovemRoles


class NovemGroupAPI(NovemAPI):
roles: Optional[NovemRoles] = None
profile: Optional[NovemGroupProfile] = None
config: Optional[NovemGroupConfig] = None
id: str

_type: str = "NA"
Expand All @@ -34,10 +34,10 @@ def __init__(self, **kwargs: Any) -> None:
self.api_create("")

self.roles = NovemRoles(self)
self.profile = NovemGroupProfile(self, type=self._type)
self.config = NovemGroupConfig(self, type=self._type)

if "profile" in kwargs:
self.profile.set(kwargs["profile"])
if "config" in kwargs:
self.config.set(kwargs["config"])

if "roles" in kwargs:
self.roles.set(kwargs["roles"])
Expand All @@ -51,7 +51,7 @@ def _parse_kwargs(self, **kwargs: Any) -> None:

# get a list of valid properties
props = [
x for x in dir(self) if x[0] != "_" and x not in ["data", "read", "delete", "write", "roles", "profile"]
x for x in dir(self) if x[0] != "_" and x not in ["data", "read", "delete", "write", "roles", "config"]
]

for k, v in kwargs.items():
Expand All @@ -63,8 +63,8 @@ def _parse_kwargs(self, **kwargs: Any) -> None:
def __setattr__(self, name: str, value: Any) -> None:
if name == "roles" and self.roles:
self.roles.set(value)
if name == "profile" and self.profile:
self.profile.set(value)
if name == "config" and self.config:
self.config.set(value)
else:
super().__setattr__(name, value)

Expand Down
39 changes: 20 additions & 19 deletions novem/group/profile.py → novem/group/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@ def __init__(self, api: "NovemGroupAPI") -> None:
"""Initialize organization options with API reference"""
self.api: "NovemGroupAPI" = api

def set(self, profile: Dict[str, Any]) -> None:
def set(self, options: Dict[str, Any]) -> None:
"""
Set profile options
Set config options
"""

props = [x for x in dir(self) if x not in ["api", "set"] and x[0] != "_"]

for k in profile.keys():
for k in options.keys():
if k not in props:
continue
v = profile[k]
v = options[k]
setattr(self, k, v)

@property
def is_open(self) -> bool:
res = self.api.api_read("/profile/options/is_open")
if res.lower() == "yes":
if res.strip().lower() == "yes":
return True
return False

Expand All @@ -37,7 +37,7 @@ def is_open(self, value: bool) -> None:
@property
def show_description(self) -> bool:
res = self.api.api_read("/profile/options/show_description")
if res.lower() == "yes":
if res.strip().lower() == "yes":
return True
return False

Expand All @@ -49,7 +49,7 @@ def show_description(self, value: bool) -> None:
@property
def show_members(self) -> bool:
res = self.api.api_read("/profile/options/show_members")
if res.lower() == "yes":
if res.strip().lower() == "yes":
return True
return False

Expand All @@ -61,7 +61,7 @@ def show_members(self, value: bool) -> None:
@property
def show_profile(self) -> bool:
res = self.api.api_read("/profile/options/show_profile")
if res.lower() == "yes":
if res.strip().lower() == "yes":
return True
return False

Expand All @@ -76,7 +76,7 @@ class OrgOptions(GroupOptions):
def enable_subdomain(self) -> bool:
res = self.api.api_read("/profile/options/enable_subdomain")

if res.lower() == "yes":
if res.strip().lower() == "yes":
return True
return False

Expand All @@ -91,7 +91,7 @@ class OrgGroupOptions(GroupOptions):
def allow_inbound_mail(self) -> bool:
res = self.api.api_read("/profile/options/allow_inbound_mail")

if res.lower() == "yes":
if res.strip().lower() == "yes":
return True
return False

Expand All @@ -104,7 +104,7 @@ def allow_inbound_mail(self, value: bool) -> None:
def mail_verify_dkim(self) -> bool:
res = self.api.api_read("/profile/options/mail_verify_dkim")

if res.lower() == "yes":
if res.strip().lower() == "yes":
return True
return False

Expand All @@ -117,7 +117,7 @@ def mail_verify_dkim(self, value: bool) -> None:
def mail_verify_spf(self) -> bool:
res = self.api.api_read("/profile/options/mail_verify_spf")

if res.lower() == "yes":
if res.strip().lower() == "yes":
return True
return False

Expand All @@ -127,9 +127,10 @@ def mail_verify_spf(self, value: bool) -> None:
return self.api.api_write("/profile/options/mail_verify_spf", val)


class NovemGroupProfile(object):
class NovemGroupConfig(object):
"""
Novem group profile
Novem group config: the group's name, description and options, stored
under ``/profile`` in the API
"""

options: GroupOptions
Expand All @@ -143,20 +144,20 @@ def __init__(self, api: "NovemGroupAPI", type: str = "group") -> None:
elif type == "org_group":
self.options = OrgGroupOptions(api)

def set(self, profile: Dict[str, Any]) -> None:
def set(self, config: Dict[str, Any]) -> None:
"""
Set profile options
Set config options
"""

props = [x for x in dir(self) if x in ["name", "description", "options"]]

for k in profile.keys():
for k in config.keys():
if k not in props:
continue

v = profile[k]
v = config[k]
if k == "options":
self.options.set(profile[k])
self.options.set(config[k])
else:
setattr(self, k, v)

Expand Down
Loading
Loading