diff --git a/docs/maturity-and-projects.md b/docs/maturity-and-projects.md index e667a68..235ad08 100644 --- a/docs/maturity-and-projects.md +++ b/docs/maturity-and-projects.md @@ -132,6 +132,12 @@ Every attribute has: Values are never secret (`secret` is not allowed; secret markers are refused). Only references to secrets are allowed. +### Managing attribute sets in the UI + +The **Attribute sets** section lists every set with where it lives, what it extends and who reuses it (projects, templates, other sets). A set's +page shows what it resolves to, with the source of each value and a *modified* mark where a later layer replaced one. Sets stored in a repository +can be created and edited there; your own `user-attributes` set is edited on the settings page. Deleting sets is not offered yet. + ### The active user: `user-attributes` One attribute set lives in the app rather than in any project: `user-attributes`, holding the active user's `operator` (one word) and an diff --git a/rtut/attrs.py b/rtut/attrs.py index aaead93..ef6cf99 100644 --- a/rtut/attrs.py +++ b/rtut/attrs.py @@ -144,3 +144,88 @@ def check_value(pack: P.Pack, name: str, value) -> str | None: except (ValueError, KeyError) as e: return str(e) return None + + +# ---- managing attribute sets --------------------------------------------------------------------------------------- + +import re as _re + +SET_ID = _re.compile(r"[a-z0-9]+([.-][a-z0-9]+)*") +ATTR_NAME = _re.compile(r"[a-z][a-z0-9_]*") + + +def used_by(reg: Registry, set_id: str) -> dict[str, list[str]]: + """Who reuses an attribute set: projects and templates (also through targets), and sets that extend it.""" + out = {"projects": [], "templates": [], "sets": []} + for kind, docs in (("projects", reg.projects), ("templates", reg.templates)): + for did, d in docs.items(): + refs = list(d.data.get("attribute_sets", [])) + for t in d.data.get("targets", {}).values(): + refs += t.get("attribute_sets", []) + if set_id in refs: + out[kind].append(did) + for sid, d in reg.sets.items(): + if sid != set_id and set_id in d.data.get("extends", []): + out["sets"].append(sid) + return out + + +def _would_cycle(reg: Registry, set_id: str, extends: list[str]) -> bool: + def reaches(start: str, seen: set) -> bool: + if start == set_id: + return True + if start in seen or start not in reg.sets: + return False + seen.add(start) + return any(reaches(x, seen) for x in reg.sets[start].data.get("extends", [])) + return any(reaches(x, set()) for x in extends) + + +def save_set(reg: Registry, root: Path, set_id: str, title: str, extends: list[str], + attributes: dict[str, dict], existing_path: Path | None = None) -> Path: + """Write an attribute set file. `attributes` maps name -> {value, requirement, note} (any part may be empty). + + Validates the id, title, names, secrets, that extended sets exist and that nothing becomes circular. + """ + if not SET_ID.fullmatch(set_id or ""): + raise ValueError("the id is lower-case letters, digits, dots and hyphens") + if len((title or "").strip()) < 3: + raise ValueError("the title needs at least 3 characters") + extends = [x for x in extends if x] + for x in extends: + if x == set_id: + raise ValueError("a set cannot extend itself") + if x not in reg.sets: + raise ValueError(f"unknown attribute set {x} in extends") + if _would_cycle(reg, set_id, extends): + raise ValueError("that extends list would make the sets circular") + out: dict = {} + for name, a in attributes.items(): + if not ATTR_NAME.fullmatch(name): + raise ValueError(f"bad attribute name {name!r}: lower-case letters, digits and underscores, starting with a letter") + value = a.get("value") + value = None if value is None or str(value).strip() == "" else str(value).strip() + req = a.get("requirement") or None + if req not in (None, "required", "optional"): + raise ValueError(f"{name}: requirement is required or optional") + note = (a.get("note") or "").strip() or None + if value is None and req is None and note is None: + raise ValueError(f"{name}: give a value, a requirement or a note") + for text in (value or "", note or ""): + if SECRET_RE.search(text): + raise ValueError(f"{name}: looks like a secret; attribute sets are never secret") + entry = {k: v for k, v in (("value", value), ("requirement", req), ("note", note)) if v is not None} + out[name] = entry["value"] if list(entry) == ["value"] else entry + if SECRET_RE.search(title): + raise ValueError("the title looks like a secret") + doc: dict = {"spec": "attribute-set/v0.1", "id": set_id, "title": title.strip()} + if extends: + doc["extends"] = extends + doc["attributes"] = out + errs = _schema_errors(doc, "attribute-set.schema.json") + if errs: + raise ValueError(errs[0]) + path = existing_path or (Path(root) / "attribute-sets" / f"{set_id}.yaml") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(doc, sort_keys=False)) + return path diff --git a/rtut/project.py b/rtut/project.py index 57c2b0f..61aad2e 100644 --- a/rtut/project.py +++ b/rtut/project.py @@ -295,3 +295,27 @@ def set_attributes(reg: Registry, doc: Doc, values: dict[str, str]) -> Path: text["attributes"] = attrs doc.path.write_text(yaml.safe_dump(text, sort_keys=False)) return doc.path + + +def update_project(doc: Doc, title: str, description: str = "", goal: str = "") -> Path: + """Rename a project and edit its description and goal, in its own file.""" + if doc.data.get("spec") != "project/v0.1": + raise ValueError("only a project can be edited this way") + from .pack import SECRET_RE + title = title.strip() + if len(title) < 3: + raise ValueError("the project name needs at least 3 characters") + for label, text in (("name", title), ("description", description), ("goal", goal)): + if SECRET_RE.search(text): + raise ValueError(f"the {label} looks like a secret; projects are never secret") + data = yaml.safe_load(doc.path.read_text()) + data["title"] = title + for key, text in (("description", description), ("goal", goal)): + if text.strip(): + data[key] = text.strip() + else: + data.pop(key, None) + ordered = {k: data[k] for k in ("spec", "id", "title", "description", "goal") if k in data} + ordered.update({k: v for k, v in data.items() if k not in ordered}) + doc.path.write_text(yaml.safe_dump(ordered, sort_keys=False)) + return doc.path diff --git a/rtut/ui.py b/rtut/ui.py index d7d3921..17a4ef5 100644 --- a/rtut/ui.py +++ b/rtut/ui.py @@ -14,13 +14,19 @@ from . import pack as P, run as R from . import ui_projects as UP from . import ui_v2 as UI2 from . import activity as ACT, user as USER +from . import ui_attrs as UA from .attrs import Registry +LIGHT = "--fg:#141414;--bg:#fbfbf9;--mut:#6b6b66;--line:#d9d9d2;--card:#fff;--hi:#f2e35a;--ok:#1a7f37;--warn:#9a6700;--bad:#c62828;--side:#f3f3ef" +DARK = "--fg:#ececea;--bg:#121212;--mut:#9a9a94;--line:#2e2e2b;--card:#191918;--hi:#f2e35a;--ok:#56d364;--warn:#e3b341;--bad:#ff7b72;--side:#0e0e0d" CSS = """ -:root{--fg:#141414;--bg:#fbfbf9;--mut:#6b6b66;--line:#d9d9d2;--card:#fff;--hi:#f2e35a;--ok:#1a7f37;--warn:#9a6700;--bad:#c62828;--radius:6px} -@media(prefers-color-scheme:dark){:root{--fg:#ececea;--bg:#121212;--mut:#9a9a94;--line:#2e2e2b;--card:#191918;--hi:#f2e35a;--ok:#56d364;--warn:#e3b341;--bad:#ff7b72}} +:root{%s;--radius:6px;color-scheme:light} +@media(prefers-color-scheme:dark){:root:not([data-theme=light]){%s;color-scheme:dark}} +:root[data-theme=dark]{%s;color-scheme:dark} +:root[data-theme=light]{color-scheme:light} +""" % (LIGHT, DARK, DARK) + """ *{box-sizing:border-box} -body{font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;max-width:56rem;margin:2rem auto;padding:0 1.2rem;color:var(--fg);background:var(--bg)} +body{font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;color:var(--fg);background:var(--bg)} h1{font-size:1.6rem;font-weight:650;letter-spacing:-.01em;margin:.4rem 0 .6rem} h2{font-size:1.1rem;font-weight:650;margin:1.8rem 0 .5rem;padding-bottom:.25rem;border-bottom:1px solid var(--line)} h3{font-size:.95rem;margin:1.2rem 0 .3rem} @@ -42,6 +48,7 @@ table{border-collapse:collapse;width:100%;margin:.5rem 0;background:var(--card); td,th{border-bottom:1px solid var(--line);padding:.4rem .6rem;text-align:left;vertical-align:top}th{font:11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.06em;color:var(--mut)} tr:last-child td{border-bottom:0} table.data td:first-child{width:42%} +table.edit input,table.edit select{width:100%} td label{display:block} td input:not([type=checkbox]):not([type=hidden]):not([type=radio]){width:100%} .row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:12rem}[hidden]{display:none} @@ -70,16 +77,47 @@ a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{ .pill[data-s=nil],.pill[data-s=todo],.pill[data-s=open],.pill[data-s=planned],.pill[data-s="not checked"],.pill[data-s=not-applied]{color:var(--mut);border-style:dashed} .feed{list-style:none;padding:0}.feed li{padding:.3rem 0;border-bottom:1px dashed var(--line)} .checks{list-style:none;padding:0;margin:.4rem 0}.checks li{padding:.35rem 0;border-bottom:1px dashed var(--line)}.checks li:last-child{border:0} -#current{scroll-margin-top:1rem}details>summary{list-style-position:outside} +#current{scroll-margin-top:1rem} +.app{display:grid;grid-template-columns:15.5rem minmax(0,1fr);min-height:100vh} +main{max-width:58rem;width:100%;padding:1.8rem 1.6rem 4rem} +.side{position:sticky;top:0;align-self:start;height:100vh;display:flex;flex-direction:column;gap:.2rem;padding:1.1rem .8rem;background:var(--side);border-right:1px solid var(--line)} +.brand{font:12px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;text-decoration:none;padding:.3rem .6rem 1rem;color:var(--fg)} +.side nav{display:flex;flex-direction:column;gap:.1rem} +.side a[data-nav]{display:block;padding:.4rem .7rem;border-radius:var(--radius);text-decoration:none;color:var(--mut)} +.side a[data-nav]:hover{background:var(--hi);color:#141414} +.side a[data-nav][aria-current=page]{background:var(--card);color:var(--fg);font-weight:650;box-shadow:inset 3px 0 0 var(--fg)} +.side-foot{margin-top:auto;display:flex;flex-direction:column;gap:.5rem;border-top:1px solid var(--line);padding-top:.8rem} +.side-foot a.me{display:flex;gap:.5rem;align-items:center} +.avatar{display:inline-grid;place-items:center;width:1.7rem;height:1.7rem;border-radius:99px;background:var(--fg);color:var(--bg);font:12px ui-monospace,monospace} +.side-foot button{background:var(--card);color:var(--fg);border-color:var(--line);font-size:.85rem} +@media(max-width:52rem){.app{grid-template-columns:1fr}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:.6rem .8rem} +.brand{padding:.3rem .6rem}.side nav{flex-direction:row;flex-wrap:wrap}.side-foot{margin:0 0 0 auto;border:0;padding:0;flex-direction:row;align-items:center}main{padding:1.2rem .9rem 3rem}}details>summary{list-style-position:outside} @media(max-width:40rem){body{margin:1rem auto}.head{display:block;width:auto}.tabs{overflow:auto}} -@media print{button,.tabs,form{display:none}.step,.stage{break-inside:avoid}} +@media print{.side,button,.tabs,form{display:none}.app{display:block}.step,.stage{break-inside:avoid}} """ +NAV = (("Home", "/"), ("Projects", "/projects"), ("Runbooks", "/runbooks"), ("Attribute sets", "/attribute-sets"), ("Activity", "/activity")) + +# applied before the page paints, so a saved theme never flashes; ?theme=dark|light|system also sets it +THEME_INIT = ("(function(){try{var q=new URLSearchParams(location.search).get('theme');" + "if(q==='dark'||q==='light')localStorage.setItem('rtut-theme',q);else if(q==='system')localStorage.removeItem('rtut-theme');" + "var t=localStorage.getItem('rtut-theme');" + "if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);" + "else document.documentElement.removeAttribute('data-theme')}catch(x){}})()") + + def page(title: str, body: str) -> bytes: - return (f"" - f"{html.escape(title)}" - f"

runbook-tutorials

{body}" + u = USER.current() + label = u["display_name"] or u["operator"] + nav = "".join(f"{html.escape(t)}" for t, h in NAV) + side = (f"") + return (f"" + f"{html.escape(title)}" + f"
{side}
{body}
" "").encode() + "").encode() def e(s) -> str: @@ -130,15 +176,16 @@ class App: return (f"" f"

Running as {label} · change

") - def activities_html(self, limit: int = 12) -> str: + def activities_html(self, limit: int = 12, level: str = "h2") -> str: events = ACT.latest(self.packs, limit) if not events: - return "

Latest activities

Nothing has happened yet. Start a run or a project.

" + return f"<{level}>Latest activities

Nothing has happened yet. Start a run or a project.

" rows = "".join( f"
  • {e(x['at'][:16].replace('T', ' '))} · {e(x['who'])} {e(x['text'])} " f"· {e(x['pack'])}{' · project ' + e(x['project']) if x.get('project') else ''}" f"{' · open' if x['link'] else ''}
  • " for x in events) - return f"

    Latest activities (times in UTC)

    " + return (f"<{level}>Latest activities (times in UTC)" + f"") def user_page(self, msg: str = "") -> bytes: u = USER.current() @@ -161,6 +208,18 @@ class App: return f"" def index(self) -> bytes: + return page("Home", UP.index_sections(self) + self.activities_html() + self.runbooks_html()) + + def runbooks_page(self) -> bytes: + return page("Runbooks", self.runbooks_html("h1")) + + def projects_page(self) -> bytes: + return page("Projects", UP.index_sections(self)) + + def activity_page(self) -> bytes: + return page("Activity", self.activities_html(100, "h1")) + + def runbooks_html(self, level: str = "h2") -> str: rows = [] for i, p in enumerate(self.packs): d = p.data @@ -172,7 +231,7 @@ class App: else: rows.append(f"
  • {e(d['title'])} {UP.maturity_pill(p)} " f"{e(score_line(R.scorecard(p)))}
  • ") - return page("Runbooks", UP.index_sections(self) + self.activities_html() + f"

    Runbooks

    ") + return f"<{level}>Runbooks" def pack_page(self, i: int) -> bytes: p = self.packs[i]; d = p.data @@ -407,6 +466,18 @@ def make_handler(app: App, port_holder: list): return self._send(app.pack_page(int(parts[1]))) if parts == ["user"]: return self._send(app.user_page()) + if parts == ["projects"]: + return self._send(app.projects_page()) + if parts == ["runbooks"]: + return self._send(app.runbooks_page()) + if parts == ["activity"]: + return self._send(app.activity_page()) + if parts == ["attribute-sets"]: + return self._send(UA.sets_page(app)) + if len(parts) == 2 and parts[0] == "attribute-set": + body = UA.set_page(app, parts[1]) + if body: + return self._send(body) if parts[0] == "run" and len(parts) == 5 and parts[3] == "artefact": status, body, ctype, dl = UI2.serve_artefact(app, int(parts[1]), parts[2], parts[4]) want_dl = bool(parse_qs(urlparse(self.path).query).get("download")) @@ -437,6 +508,13 @@ def make_handler(app: App, port_holder: list): form["operator"] = app.operator() # the active user, unless a form names someone if not form.get("by", "").strip(): form["by"] = app.operator() + if parts == ["attribute-sets", "new"] or (len(parts) == 2 and parts[0] == "attribute-set"): + try: + return self._redirect(UA.handle_post(app, parts, form)) + except (ValueError, KeyError) as ex: + if parts[0] == "attribute-set" and len(parts) == 2: + return self._send(UA.set_page(app, parts[1], str(ex)) or page("Not found", "

    Not found.

    "), 400) + return self._send(UA.sets_page(app, str(ex)), 400) if parts == ["user"]: try: USER.save(form.get("operator", ""), form.get("display_name", "")) @@ -455,6 +533,11 @@ def make_handler(app: App, port_holder: list): except M.ReviewError as ex: raise ValueError(str(ex)) return self._redirect(f"/pack/{i}#maturity") + if len(parts) == 3 and parts[0] == "project" and parts[2] == "edit": + from . import project as J + doc = app.registry().projects[parts[1]] + J.update_project(doc, form.get("title", ""), form.get("description", ""), form.get("goal", "")) + return self._redirect(f"/project/{parts[1]}") if len(parts) == 3 and parts[0] == "project" and parts[2] in ("attributes", "run"): from . import project as J reg = app.registry(); doc = reg.projects[parts[1]] diff --git a/rtut/ui_attrs.py b/rtut/ui_attrs.py new file mode 100644 index 0000000..4efc82e --- /dev/null +++ b/rtut/ui_attrs.py @@ -0,0 +1,170 @@ +"""UI pages for attribute sets: list, inspect (with what they resolve to and who reuses them), create and edit.""" +from __future__ import annotations + +import html +from pathlib import Path + +from . import user as USER +from .attrs import fold, save_set, set_layers, used_by + +EXTRA_ROWS = 3 + + +def e(s) -> str: + return html.escape(str(s)) + + +def pill(text: str) -> str: + return f"{e(text)}" + + +def _is_app_level(doc) -> bool: + return Path(doc.path) == USER.user_file() + + +def _where(app, doc) -> str: + if _is_app_level(doc): + return "app (you)" + for r in app.roots: + try: + return f"{r.name}/{Path(doc.path).relative_to(r)}" + except ValueError: + continue + return str(doc.path) + + +def _usage(app, reg, sid: str) -> str: + u = used_by(reg, sid) + parts = [] + parts += [f"{e(p)}" for p in u["projects"]] + parts += [f"{e(t)} (template)" for t in u["templates"]] + parts += [f"{e(x)} (extends it)" for x in u["sets"]] + return ", ".join(parts) or "nothing yet" + + +def sets_page(app, msg: str = "") -> bytes: + from .ui import page + reg = app.registry() + rows = "" + for sid, doc in sorted(reg.sets.items()): + n = len(doc.data.get("attributes", {})) if doc.ok else 0 + state = "" if doc.ok else f" invalid: {e(doc.errors[0])}" + ext = ", ".join(f"{e(x)}" for x in doc.data.get("extends", [])) or "-" + rows += (f"{e(doc.data.get('title', sid))}{state}
    {e(sid)}" + f"{e(_where(app, doc))}{ext}{n}{_usage(app, reg, sid)}") + roots = "".join(f"" for i, r in enumerate(app.roots)) + extends_help = ", ".join(sorted(reg.sets)) or "none yet" + body = ("

    Attribute sets

    " + "

    Named, reusable, non-secret facts. Runbooks receive them as inputs, projects and templates compose them, and one set " + "can extend another (later layers win, and every value keeps its source). Nothing here may be a secret.

    " + + (f"

    {e(msg)}

    " if msg else "") + + ("" + rows + "
    SetWhere it livesExtendsAttributesReused by
    " + if rows else "

    No attribute sets yet.

    ") + + "
    New attribute set" + f"
    {app.hidden()}" + "

    " + "

    " + f"

    " + f"

    " + "
    ") + return page("Attribute sets", body) + + +def set_page(app, sid: str, msg: str = "") -> bytes | None: + from .ui import page + reg = app.registry() + doc = reg.sets.get(sid) + if doc is None: + return None + d = doc.data + out = [f"

    {e(d.get('title', sid))}

    {pill('app' if _is_app_level(doc) else 'repository')} {e(sid)} " + f"{e(_where(app, doc))}

    "] + if msg: + out.append(f"

    {e(msg)}

    ") + if not doc.ok: + out.append("

    This set is invalid:

    ") + return page(d.get("title", sid), "".join(out)) + out.append(f"

    Reused by: {_usage(app, reg, sid)}

    ") + try: + folded = fold(set_layers(reg, sid)) + except ValueError as ex: + out.append(f"

    {e(ex)}

    ") + folded = {} + rows = "" + for name, a in folded.items(): + val = "" if a["value"] is None else a["value"] + rows += (f"{e(name)}{e(val) if val != '' else '(no value)'}" + f"{e(a['requirement'] or '-')}{e(a.get('note', ''))}{e(a['source'] or '-')}" + f"{' ' + pill('modified') if a['modified'] else ''}") + out.append("

    What this set resolves to

    Including everything it extends; the source shows which set gave each value.

    " + "" + rows + "
    AttributeValueRequirementNoteSource
    ") + if _is_app_level(doc): + out.append("

    This is your set: it follows you across every project. Change it on " + "your settings page. A repository may define its own set with the same id, which then wins.

    ") + return page(d.get("title", sid), "".join(out)) + own = d.get("attributes", {}) + trs = "" + from .attrs import normalize + for i, (name, a) in enumerate(normalize(own).items()): + req = a.get("requirement") or "" + opts = "".join(f"" for o in ("", "required", "optional")) + trs += (f"" + f"" + f"") + base = len(own) + for j in range(EXTRA_ROWS): + i = base + j + trs += (f"" + f"" + f"") + warn = "" + u = used_by(reg, sid) + if u["projects"] or u["templates"] or u["sets"]: + warn = ("

    Other things reuse this set. Changing a value changes what their runbooks receive from now on; " + "runs that already started keep the values they started with.

    ") + out.append("

    Edit

    " + warn + + f"
    {app.hidden()}" + f"

    " + f"

    " + f"" + "" + trs + "
    AttributeValueRequirementNote
    " + "

    A value can be left empty for a required attribute: it then has to be given by whoever uses the set. " + "Never enter secrets.

    ") + return page(d.get("title", sid), "".join(out)) + + +def _split(text: str) -> list[str]: + return [x for x in text.replace(",", " ").split() if x] + + +def handle_post(app, parts: list[str], form: dict) -> str: + """Create or edit an attribute set. Returns the URL to go to; raises ValueError with the reason if refused.""" + reg = app.registry() + if parts == ["attribute-sets", "new"]: + try: + root = app.roots[int(form.get("root", "0"))] + except (ValueError, IndexError): + raise ValueError("choose a repository") + sid = form.get("set_id", "").strip() + if sid in reg.sets: + raise ValueError(f"an attribute set {sid} already exists") + save_set(reg, root, sid, form.get("title", ""), _split(form.get("extends", "")), {}) + return f"/attribute-set/{sid}" + if len(parts) == 2 and parts[0] == "attribute-set": + sid = parts[1] + doc = reg.sets.get(sid) + if doc is None: + raise KeyError(sid) + if _is_app_level(doc): + raise ValueError("this is your app-level set: change it on your settings page") + attrs: dict = {} + for i in range(int(form.get("rows", "0") or 0)): + name = form.get(f"n{i}", "").strip() + if not name or form.get(f"x{i}"): + continue + if name in attrs: + raise ValueError(f"{name} appears twice") + attrs[name] = {"value": form.get(f"v{i}", ""), "requirement": form.get(f"r{i}", ""), "note": form.get(f"t{i}", "")} + save_set(reg, Path(doc.path).parent.parent, sid, form.get("title", ""), _split(form.get("extends", "")), attrs, existing_path=Path(doc.path)) + return f"/attribute-set/{sid}" + raise ValueError("unknown action") diff --git a/rtut/ui_projects.py b/rtut/ui_projects.py index a8199ff..313508a 100644 --- a/rtut/ui_projects.py +++ b/rtut/ui_projects.py @@ -120,6 +120,12 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None: out.append(f"

    {e(d['description'])}

    ") if d.get("goal"): out.append(f"

    Goal: {e(d['goal'])}

    ") + out.append(f"
    Edit name, description and goal" + f"
    {app.hidden()}" + f"

    " + f"

    " + f"

    " + "

    The project id and its template stay as they are. Never enter secrets.

    ") # what is still needed needs: dict[str, list[str]] = {} for n in st["needs"]: diff --git a/tests/test_projects.py b/tests/test_projects.py index 3cbadef..36e271a 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -249,3 +249,67 @@ def test_project_without_a_given_name_uses_its_id_never_the_templates_title(repo J.instantiate(reg, "lighthouse-watch", "ab", "staging") with pytest.raises(ValueError, match="secret"): J.instantiate(reg, "lighthouse-watch", "leaky-one", "staging", description="token hvs.ABCDEFGHIJKLMN") + + +# ---- managing attribute sets and editing projects -------------------------------------------------------------------- + +def test_used_by_finds_projects_templates_targets_and_extending_sets(repo): + from rtut.attrs import used_by + reg = project(repo) # north-watch uses stormy-coast, which extends harbour-defaults + u = used_by(reg, "stormy-coast") + assert u == {"projects": ["north-watch"], "templates": [], "sets": []} + u = used_by(reg, "harbour-defaults") + assert u["templates"] == ["lighthouse-watch"] and u["sets"] == ["stormy-coast"] + + +def test_save_set_writes_validates_and_normalizes(repo): + from rtut.attrs import save_set + reg = Registry([repo]) + path = save_set(reg, repo, "team-defaults", "Team defaults", ["harbour-defaults"], + {"lamp": {"value": "electric"}, "keeper": {"requirement": "required", "note": "who"}, "empty_note": {"note": "later"}}) + made = yaml.safe_load(path.read_text()) + assert path == repo / "attribute-sets/team-defaults.yaml" and made["extends"] == ["harbour-defaults"] + assert made["attributes"]["lamp"] == "electric" # a lone value is written as a scalar + assert made["attributes"]["keeper"] == {"requirement": "required", "note": "who"} + reg = Registry([repo]) + assert reg.sets["team-defaults"].ok and fold(set_layers(reg, "team-defaults"))["lamp"]["value"] == "electric" + + +@pytest.mark.parametrize("kwargs,frag", [ + ({"set_id": "Bad Id"}, "lower-case"), ({"title": "ab"}, "3 characters"), ({"extends": ["ghost"]}, "unknown attribute set"), + ({"extends": ["team-x"]}, "itself"), ({"attributes": {"Bad-Name": {"value": "x"}}}, "bad attribute name"), + ({"attributes": {"a": {"value": "hvs.ABCDEFGHIJKLMN"}}}, "secret"), ({"attributes": {"a": {"requirement": "maybe", "value": "x"}}}, "requirement"), + ({"attributes": {"a": {}}}, "give a value"), +]) +def test_save_set_refusals(repo, kwargs, frag): + from rtut.attrs import save_set + args = {"set_id": "team-x", "title": "Team x", "extends": [], "attributes": {"a": {"value": "1"}}, **kwargs} + with pytest.raises(ValueError, match=frag): + save_set(Registry([repo]), repo, **args) + assert not (repo / "attribute-sets/team-x.yaml").exists() + + +def test_save_set_refuses_a_cycle(repo): + from rtut.attrs import save_set + reg = Registry([repo]) + with pytest.raises(ValueError, match="circular"): + save_set(reg, repo, "harbour-defaults", "Harbour defaults", ["stormy-coast"], {"lamp": {"value": "x"}}, reg.sets["harbour-defaults"].path) + + +def test_update_project_renames_and_keeps_everything_else(repo): + reg = project(repo) + doc = reg.projects["north-watch"] + before = yaml.safe_load(doc.path.read_text()) + J.update_project(doc, "North harbour watch", "Our harbour", "Both lights burn") + after = yaml.safe_load(doc.path.read_text()) + assert after["title"] == "North harbour watch" and after["description"] == "Our harbour" and after["goal"] == "Both lights burn" + assert after["runbooks"] == before["runbooks"] and after["attributes"] == before["attributes"] and after["id"] == "north-watch" + assert list(after)[:5] == ["spec", "id", "title", "description", "goal"] + J.update_project(Registry([repo]).projects["north-watch"], "North harbour watch", "", "") + assert "description" not in yaml.safe_load(doc.path.read_text()) + for bad, frag in (("ab", "3 characters"), ("x" * 5 + "hvs.ABCDEFGHIJKLMN", "secret")): + with pytest.raises(ValueError, match=frag): + J.update_project(Registry([repo]).projects["north-watch"], bad) + reg = Registry([repo]) + with pytest.raises(ValueError, match="only a project"): + J.update_project(reg.templates["lighthouse-watch"], "Renamed template") diff --git a/tests/test_ui_shell.py b/tests/test_ui_shell.py new file mode 100644 index 0000000..63e9d3d --- /dev/null +++ b/tests/test_ui_shell.py @@ -0,0 +1,197 @@ +import http.client +import shutil +import threading +from pathlib import Path +from urllib.parse import urlencode + +import pytest +import yaml + +from rtut import ui, user + +ROOT = Path(__file__).resolve().parent.parent + + +def write(path: Path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(data, sort_keys=False)) + + +@pytest.fixture +def srv(tmp_path): + shutil.copy(ROOT / "runbooks/showcase-lighthouse/pack.yaml", tmp_path / "runbooks/show/pack.yaml") if (tmp_path / "runbooks/show").mkdir(parents=True) is None else None + for f in (ROOT / "attribute-sets").glob("*.yaml"): + write(tmp_path / "attribute-sets" / f.name, yaml.safe_load(f.read_text())) + (tmp_path / "project-templates").mkdir() + shutil.copy(ROOT / "project-templates/lighthouse-watch.yaml", tmp_path / "project-templates/lighthouse-watch.yaml") + write(tmp_path / "projects/watch/project.yaml", { + "spec": "project/v0.1", "id": "watch", "title": "Watch project", "description": "Old description", "environment": "rehearsal", + "attribute_sets": ["stormy-coast", "user-attributes"], "attributes": {"logdir": "/tmp/rtut-shell-x", "keeper": "Ada"}, + "runbooks": [{"pack": "rtut.showcase-lighthouse"}]}) + s = ui.serve([tmp_path], 0) + threading.Thread(target=s.serve_forever, daemon=True).start() + s.repo = tmp_path + yield s + s.shutdown() + + +def req(s, method, path, form=None): + c = http.client.HTTPConnection("127.0.0.1", s.server_address[1]) + body = urlencode(form) if form is not None else None + c.request(method, path, body, {"Host": "127.0.0.1", "Content-Type": "application/x-www-form-urlencoded"}) + r = c.getresponse() + return r.status, r.getheader("Location"), r.read().decode() + + +def post(s, path, **form): + return req(s, "POST", path, {"_t": s.app.token, **form}) + + +# ---- shell: sidebar, user, theme --------------------------------------------------------------------------------- + +PAGES = ["/", "/projects", "/runbooks", "/activity", "/attribute-sets", "/user", "/pack/0", "/project/watch", + "/template/lighthouse-watch", "/attribute-set/harbour-defaults"] + + +@pytest.mark.parametrize("path", PAGES) +def test_every_page_has_the_sidebar_with_sections_user_and_theme_switch(srv, path): + st, _, body = req(srv, "GET", path) + assert st == 200 + assert "