diff --git a/.github/workflows/romm-release-bump.yml b/.github/workflows/romm-release-bump.yml new file mode 100644 index 00000000..d4af16fb --- /dev/null +++ b/.github/workflows/romm-release-bump.yml @@ -0,0 +1,160 @@ +name: Bump pinned romm ref + +# The generated reference pages (env vars, scheduled tasks, supported platforms) +# are built from rommapp/romm at the ref pinned in scripts/sources.toml. Nothing +# was moving that pin, so it sat on 5.1.0-beta.2 while upstream shipped 5.2.0 and +# every variable added in between went undocumented (#124). +# +# This polls for new upstream releases and opens a PR with the regenerated +# snippets. rommapp/romm can also trigger it directly by sending a +# `romm-release` repository_dispatch on publish. + +on: + schedule: + # Daily at 06:00 UTC. Upstream releases are infrequent, so this is + # really a safety net. + - cron: 0 6 * * * + workflow_dispatch: + inputs: + # trunk-ignore(checkov/CKV_GHA_7) + ref: + description: Ref to pin (defaults to romm's latest stable release) + required: false + type: string + repository_dispatch: + types: [romm-release] + +concurrency: + group: romm-release-bump + cancel-in-progress: false + +permissions: + contents: write # Push the bump branch + pull-requests: write # Open the bump PR + +jobs: + bump: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repo + uses: actions/checkout@v4.3.0 + + - name: Resolve target ref + id: target + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_REF: ${{ github.event.inputs.ref }} + DISPATCH_REF: ${{ github.event.client_payload.ref }} + run: | + set -euo pipefail + target="${INPUT_REF:-${DISPATCH_REF:-}}" + if [ -z "$target" ]; then + target=$(gh release view --repo rommapp/romm --json tagName --jq .tagName) + fi + current=$(grep -E '^ref = ' scripts/sources.toml | sed -E 's/.*"(.*)".*/\1/') + echo "target=$target" >> "$GITHUB_OUTPUT" + echo "current=$current" >> "$GITHUB_OUTPUT" + if [ "$target" = "$current" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Already pinned to $current, nothing to do." + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Bumping $current -> $target" + fi + + - name: Install uv + if: steps.target.outputs.changed == 'true' + uses: astral-sh/setup-uv@v6.7.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Set up Python + if: steps.target.outputs.changed == 'true' + uses: actions/setup-python@v6.0.0 + with: + python-version-file: .python-version + + - name: Install dependencies + if: steps.target.outputs.changed == 'true' + run: uv sync --all-extras --dev + + - name: Update pinned ref + if: steps.target.outputs.changed == 'true' + env: + TARGET: ${{ steps.target.outputs.target }} + run: | + set -euo pipefail + sed -i -E "s|^ref = \".*\"$|ref = \"$TARGET\"|" scripts/sources.toml + grep -E '^ref = ' scripts/sources.toml + + - name: Regenerate snippets + if: steps.target.outputs.changed == 'true' + run: | + uv run python -m scripts.gen_env_vars + uv run python -m scripts.gen_scheduled_tasks + uv run python -m scripts.gen_platforms + + # Match the prettier version trunk pins in .trunk/trunk.yaml, so the + # bump PR doesn't fail its own trunk-check. + - name: Format generated snippets + if: steps.target.outputs.changed == 'true' + run: npx --yes prettier@3.5.2 --write 'docs/resources/snippets/*.md' + + # The bump PR is opened with GITHUB_TOKEN, which does not trigger + # other workflows, so pr-checks won't run on it. Build here instead + # so a bump that breaks the docs never reaches review looking green. + - name: Build docs (strict) + if: steps.target.outputs.changed == 'true' + run: uv run mkdocs build --strict + + - name: Open bump PR + if: steps.target.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET: ${{ steps.target.outputs.target }} + CURRENT: ${{ steps.target.outputs.current }} + run: | + set -euo pipefail + branch="chore/bump-romm-ref-$TARGET" + + if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + echo "Branch $branch already exists, so this bump is already open." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$branch" + + paths="scripts/sources.toml docs/resources/snippets" + if git diff --quiet -- $paths; then + echo "Ref moved but no generated output changed, nothing to open." + exit 0 + fi + + git add $paths + git commit -m "chore: bump pinned romm ref to $TARGET" + git push origin "$branch" + + { + echo "Bumps \`scripts/sources.toml\` from \`$CURRENT\` to" + echo "[\`$TARGET\`](https://github.com/rommapp/romm/releases/tag/$TARGET)" + echo "and regenerates the reference snippets." + echo + echo "The diff is upstream drift, not hand-written prose. Skim it for" + echo "anything that reads wrong, then merge." + echo + echo "\`mkdocs build --strict\` passed in the job that opened this PR." + echo "PRs opened with \`GITHUB_TOKEN\` do not trigger \`pr-checks\`, which" + echo "is why the build runs inline." + echo + echo "Opened automatically by \`.github/workflows/romm-release-bump.yml\`." + } > /tmp/pr-body.md + + gh pr create \ + --base main \ + --head "$branch" \ + --title "chore: bump pinned romm ref to $TARGET" \ + --body-file /tmp/pr-body.md diff --git a/docs/install/zimaos.md b/docs/install/zimaos.md index d38d412c..71f1d0c5 100644 --- a/docs/install/zimaos.md +++ b/docs/install/zimaos.md @@ -30,8 +30,6 @@ The defaults work out of the box, so everything below is optional. To change container settings, use the options button in the upper-right corner of the app tile. ZimaOS offers both a form-based editor and a YAML editor for the underlying compose file, so you can set volumes, ports, and env vars either way. -![The options button on the RomM app tile](../resources/zimaos/app-tile-options.png) - ![Editing RomM's volumes and environment variables](../resources/zimaos/edit-config.png) The env vars are the same ones documented in [Quick Start](../getting-started/quick-start.md) and the [Environment Variables reference](../reference/environment-variables.md). Two worth setting early: diff --git a/docs/resources/snippets/scheduled-tasks.md b/docs/resources/snippets/scheduled-tasks.md index 270e6b84..79c86ae9 100644 --- a/docs/resources/snippets/scheduled-tasks.md +++ b/docs/resources/snippets/scheduled-tasks.md @@ -1,19 +1,19 @@ -| Task | Type | Default schedule | Enable var | Schedule/delay var | Purpose | -| ------------------------------- | --------- | ---------------- | -------------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------- | -| Scheduled rescan | Scheduled | `0 3 * * *` | `ENABLE_SCHEDULED_RESCAN` | `SCHEDULED_RESCAN_CRON` | Rescan the entire ROM library for new or changed files. | -| Switch TitleDB update | Scheduled | `0 4 * * *` | `ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB` | `SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON` | Update the Nintendo Switch TitleDB index used for matching. | -| LaunchBox metadata update | Scheduled | `0 4 * * *` | `ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA` | `SCHEDULED_UPDATE_LAUNCHBOX_METADATA_CRON` | Refresh the LaunchBox metadata store. | -| Convert images to WebP | Scheduled | `0 4 * * *` | `ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP` | `SCHEDULED_CONVERT_IMAGES_TO_WEBP_CRON` | Convert existing PNG, JPG, BMP, TIFF and GIF media to WebP. | -| Cleanup orphaned resources | Scheduled | `0 5 * * *` | `ENABLE_SCHEDULED_CLEANUP_ORPHANED_RESOURCES` | `SCHEDULED_CLEANUP_ORPHANED_RESOURCES_CRON` | Delete covers, screenshots and other resources left behind by deleted ROMs. | -| RetroAchievements progress sync | Scheduled | `0 4 * * *` | `ENABLE_SCHEDULED_RETROACHIEVEMENTS_PROGRESS_SYNC` | `SCHEDULED_RETROACHIEVEMENTS_PROGRESS_SYNC_CRON` | Update RetroAchievements progress for all users. | -| Push-pull device sync | Scheduled | `*/30 * * * *` | `ENABLE_SYNC_PUSH_PULL` | `SYNC_PUSH_PULL_CRON` | Sync saves with registered devices over SSH/SFTP. | -| Netplay cleanup | Scheduled | `*/30 * * * *` | `-` | `-` | Clean up empty netplay rooms. Always on, not configurable. | -| Upload tmp cleanup | Scheduled | `0 * * * *` | `-` | `-` | Remove orphaned chunked-upload temp directories. Always on, not configurable. | -| ZIP cache cleanup | Scheduled | `0 4 * * *` | `-` | `-` | Remove stale cached ZIP files on a tiered TTL. Always on, not configurable. | -| Cleanup missing ROMs | Manual | `-` | `-` | `-` | Delete database entries for ROMs flagged as missing from the filesystem. | -| Recompute save content hashes | Manual | `-` | `-` | `-` | Recompute content hashes for stored saves. | -| Sync folder scan | Manual | `-` | `-` | `-` | Scan device sync folders for new save files. | -| Filesystem watcher | Watcher | `-` | `ENABLE_RESCAN_ON_FILESYSTEM_CHANGE` | `RESCAN_ON_FILESYSTEM_CHANGE_DELAY` | Watch the library folder and trigger a rescan on changes. | -| Sync folder watcher | Watcher | `-` | `ENABLE_SYNC_FOLDER_WATCHER` | `SYNC_FOLDER_SCAN_DELAY` | Watch the sync folder and trigger a scan on changes. | +| Task | Type | Default schedule | Enable var | Schedule/delay var | Purpose | +| ----------------------------------------- | --------- | ---------------- | -------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Scheduled netplay cleanup | Scheduled | `*/30 * * * *` | `-` | `-` | Cleans up empty netplay rooms. Always on, not configurable. | +| Scheduled ZIP cache cleanup | Scheduled | `0 4 * * *` | `-` | `-` | Removes stale cached ZIP files based on tiered TTL. Always on, not configurable. | +| Scheduled upload tmp cleanup | Scheduled | `0 * * * *` | `-` | `-` | Cleans up orphaned chunked-upload temp directories. Always on, not configurable. | +| Cleanup orphaned resources | Scheduled | `0 5 * * *` | `ENABLE_SCHEDULED_CLEANUP_ORPHANED_RESOURCES` | `SCHEDULED_CLEANUP_ORPHANED_RESOURCES_CRON` | Clean up orphaned resources in the ROMs directory. | +| Scheduled rescan | Scheduled | `0 3 * * *` | `ENABLE_SCHEDULED_RESCAN` | `SCHEDULED_RESCAN_CRON` | Rescans the entire library. | +| Scheduled Switch TitleDB update | Scheduled | `0 4 * * *` | `ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB` | `SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON` | Updates the Nintendo Switch TitleDB file. | +| Scheduled LaunchBox metadata update | Scheduled | `0 4 * * *` | `ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA` | `SCHEDULED_UPDATE_LAUNCHBOX_METADATA_CRON` | Updates the LaunchBox metadata store. | +| Convert images to WebP | Scheduled | `0 4 * * *` | `ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP` | `SCHEDULED_CONVERT_IMAGES_TO_WEBP_CRON` | Convert existing image files (PNG, JPG, BMP, TIFF, GIF) to WebP format for better performance. | +| Scheduled RetroAchievements progress sync | Scheduled | `0 4 * * *` | `ENABLE_SCHEDULED_RETROACHIEVEMENTS_PROGRESS_SYNC` | `SCHEDULED_RETROACHIEVEMENTS_PROGRESS_SYNC_CRON` | Updates RetroAchievements progress for all users. | +| Push-Pull Sync | Scheduled | `*/30 * * * *` | `ENABLE_SYNC_PUSH_PULL` | `SYNC_PUSH_PULL_CRON` | Sync saves with devices via SSH/SFTP. | +| Cleanup missing ROMs | Manual | `-` | `-` | `-` | Delete all ROMs flagged as missing from the filesystem from the database. | +| Recompute save content hashes | Manual | `-` | `-` | `-` | Re-scan every save row and rewrite content_hash with the current compute_content_hash algorithm. One-time recovery after the zip-hash dispatch fix. | +| Sync Folder Scan | Manual | `-` | `ENABLE_SYNC_FOLDER_WATCHER` | `-` | Scan device sync folders for new save files. | +| Filesystem watcher | Watcher | `-` | `ENABLE_RESCAN_ON_FILESYSTEM_CHANGE` | `RESCAN_ON_FILESYSTEM_CHANGE_DELAY` | Watch the library folder and trigger a rescan on changes. | +| Sync folder watcher | Watcher | `-` | `ENABLE_SYNC_FOLDER_WATCHER` | `SYNC_FOLDER_SCAN_DELAY` | Watch the sync folder and trigger a scan on changes. | diff --git a/docs/resources/zimaos/app-tile-options.png b/docs/resources/zimaos/app-tile-options.png deleted file mode 100644 index 9f25e3bd..00000000 Binary files a/docs/resources/zimaos/app-tile-options.png and /dev/null differ diff --git a/docs/resources/zimaos/appstore.png b/docs/resources/zimaos/appstore.png index 4a8e27df..a1bbc299 100644 Binary files a/docs/resources/zimaos/appstore.png and b/docs/resources/zimaos/appstore.png differ diff --git a/docs/resources/zimaos/edit-config.png b/docs/resources/zimaos/edit-config.png index 02597c57..4c133261 100644 Binary files a/docs/resources/zimaos/edit-config.png and b/docs/resources/zimaos/edit-config.png differ diff --git a/scripts/gen_scheduled_tasks.py b/scripts/gen_scheduled_tasks.py index afa59c7c..1b8ef945 100644 --- a/scripts/gen_scheduled_tasks.py +++ b/scripts/gen_scheduled_tasks.py @@ -1,153 +1,247 @@ -"""Generate the scheduled-tasks reference table from the romm task registry. +"""Generate the scheduled-tasks reference table from the upstream task registry. -Parse the upstream task registration calls (or, once a stable -public registry exists, import it), and generate -docs/resources/snippets/scheduled-tasks.md +Output: docs/resources/snippets/scheduled-tasks.md (a Markdown table). + +Included by administration/scheduled-tasks.md via: + --8<-- "scheduled-tasks.md" Run manually: uv run python -m scripts.gen_scheduled_tasks + +Sources, all fetched at the ref pinned in sources.toml: + + backend/startup.py which tasks the scheduler calls init() on + backend/endpoints/tasks.py which tasks the Tasks page can run by hand + backend/tasks/**.py each task's title, enabled flag and cron default + env.template resolves env constants to documented defaults + +Every env var name in the output is resolved through env.template. A task +referencing a constant env.template doesn't define fails the build instead of +printing an invented name, which is how this table drifted for several releases +(see the `*_INTERVAL_CRON` names that never existed upstream). + +Watchers aren't Task subclasses, so they can't be discovered the same way. They +stay declared in WATCHERS below, but their env vars go through the same +env.template check as everything else. """ from __future__ import annotations -from scripts._sources import write_snippet +import ast +import sys +from typing import Iterable +from scripts._sources import fetch_text, romm_raw_url, write_snippet +from scripts.gen_env_vars import parse as parse_env_template -# Sourced from rommapp/romm backend/tasks/ at the ref pinned in sources.toml. -# Task titles, `enabled=` flags and `cron_string=` defaults come from each -# task's constructor; env var names are cross-checked against env.template. -TASKS = [ - { - "name": "Scheduled rescan", - "type": "Scheduled", - "default_cron": "0 3 * * *", - "enable_var": "ENABLE_SCHEDULED_RESCAN", - "env_var": "SCHEDULED_RESCAN_CRON", - "purpose": "Rescan the entire ROM library for new or changed files.", - }, - { - "name": "Switch TitleDB update", - "type": "Scheduled", - "default_cron": "0 4 * * *", - "enable_var": "ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB", - "env_var": "SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON", - "purpose": "Update the Nintendo Switch TitleDB index used for matching.", - }, - { - "name": "LaunchBox metadata update", - "type": "Scheduled", - "default_cron": "0 4 * * *", - "enable_var": "ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA", - "env_var": "SCHEDULED_UPDATE_LAUNCHBOX_METADATA_CRON", - "purpose": "Refresh the LaunchBox metadata store.", - }, - { - "name": "Convert images to WebP", - "type": "Scheduled", - "default_cron": "0 4 * * *", - "enable_var": "ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP", - "env_var": "SCHEDULED_CONVERT_IMAGES_TO_WEBP_CRON", - "purpose": "Convert existing PNG, JPG, BMP, TIFF and GIF media to WebP.", - }, - { - "name": "Cleanup orphaned resources", - "type": "Scheduled", - "default_cron": "0 5 * * *", - "enable_var": "ENABLE_SCHEDULED_CLEANUP_ORPHANED_RESOURCES", - "env_var": "SCHEDULED_CLEANUP_ORPHANED_RESOURCES_CRON", - "purpose": "Delete covers, screenshots and other resources left behind by deleted ROMs.", - }, - { - "name": "RetroAchievements progress sync", - "type": "Scheduled", - "default_cron": "0 4 * * *", - "enable_var": "ENABLE_SCHEDULED_RETROACHIEVEMENTS_PROGRESS_SYNC", - "env_var": "SCHEDULED_RETROACHIEVEMENTS_PROGRESS_SYNC_CRON", - "purpose": "Update RetroAchievements progress for all users.", - }, - { - "name": "Push-pull device sync", - "type": "Scheduled", - "default_cron": "*/30 * * * *", - "enable_var": "ENABLE_SYNC_PUSH_PULL", - "env_var": "SYNC_PUSH_PULL_CRON", - "purpose": "Sync saves with registered devices over SSH/SFTP.", - }, - { - "name": "Netplay cleanup", - "type": "Scheduled", - "default_cron": "*/30 * * * *", - "enable_var": "-", - "env_var": "-", - "purpose": "Clean up empty netplay rooms. Always on, not configurable.", - }, - { - "name": "Upload tmp cleanup", - "type": "Scheduled", - "default_cron": "0 * * * *", - "enable_var": "-", - "env_var": "-", - "purpose": "Remove orphaned chunked-upload temp directories. Always on, not configurable.", - }, - { - "name": "ZIP cache cleanup", - "type": "Scheduled", - "default_cron": "0 4 * * *", - "enable_var": "-", - "env_var": "-", - "purpose": "Remove stale cached ZIP files on a tiered TTL. Always on, not configurable.", - }, - { - "name": "Cleanup missing ROMs", - "type": "Manual", - "default_cron": "-", - "enable_var": "-", - "env_var": "-", - "purpose": "Delete database entries for ROMs flagged as missing from the filesystem.", - }, - { - "name": "Recompute save content hashes", - "type": "Manual", - "default_cron": "-", - "enable_var": "-", - "env_var": "-", - "purpose": "Recompute content hashes for stored saves.", - }, - { - "name": "Sync folder scan", - "type": "Manual", - "default_cron": "-", - "enable_var": "-", - "env_var": "-", - "purpose": "Scan device sync folders for new save files.", - }, +# Watchers live outside backend/tasks/, so they're declared rather than +# discovered. The env var names are still validated against env.template. +WATCHERS = [ { "name": "Filesystem watcher", - "type": "Watcher", - "default_cron": "-", "enable_var": "ENABLE_RESCAN_ON_FILESYSTEM_CHANGE", "env_var": "RESCAN_ON_FILESYSTEM_CHANGE_DELAY", "purpose": "Watch the library folder and trigger a rescan on changes.", }, { "name": "Sync folder watcher", - "type": "Watcher", - "default_cron": "-", "enable_var": "ENABLE_SYNC_FOLDER_WATCHER", "env_var": "SYNC_FOLDER_SCAN_DELAY", "purpose": "Watch the sync folder and trigger a scan on changes.", }, ] +TASK_BASES = {"Task", "PeriodicTask", "RemoteFilePullTask"} +ABSENT = object() # Distinguishes "resolved to None" from "keyword not passed". + + +class UpstreamDrift(RuntimeError): + """Upstream no longer matches what this generator knows how to read.""" + + +def import_map(tree: ast.Module) -> dict[str, str]: + """Map imported task singletons to the module path they came from. -def render() -> str: + `from tasks.scheduled.scan_library import scan_library_task` + -> {"scan_library_task": "backend/tasks/scheduled/scan_library.py"} + """ + out: dict[str, str] = {} + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom) or not node.module: + continue + # `tasks.tasks` holds the base classes and TaskType, not task singletons. + if not node.module.startswith("tasks.") or node.module == "tasks.tasks": + continue + path = "backend/" + node.module.replace(".", "/") + ".py" + for alias in node.names: + out[alias.asname or alias.name] = path + return out + + +def scheduled_module_paths(startup_src: str) -> list[str]: + """Module paths for every task startup.py calls `.init()` on, in order.""" + tree = ast.parse(startup_src) + imports = import_map(tree) + + paths: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if not isinstance(func, ast.Attribute) or func.attr != "init": + continue + if not isinstance(func.value, ast.Name): + continue + path = imports.get(func.value.id) + if path and path not in paths: + paths.append(path) + + if not paths: + raise UpstreamDrift( + "no `.init()` calls found in backend/startup.py. The scheduler " + "entrypoint moved or changed shape, so this parser needs updating." + ) + return paths + + +def manual_module_paths(endpoints_src: str) -> list[str]: + """Module paths for the entries of the `manual_tasks` registry list.""" + tree = ast.parse(endpoints_src) + imports = import_map(tree) + + for node in ast.walk(tree): + target = None + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + target = node.target.id + elif isinstance(node, ast.Assign) and len(node.targets) == 1: + if isinstance(node.targets[0], ast.Name): + target = node.targets[0].id + if target != "manual_tasks" or node.value is None: + continue + + names = {n.id for n in ast.walk(node.value) if isinstance(n, ast.Name)} + paths: list[str] = [] + for name in sorted(names): + path = imports.get(name) + if path and path not in paths: + paths.append(path) + if paths: + return paths + + raise UpstreamDrift( + "no `manual_tasks` registry found in backend/endpoints/tasks.py. The " + "registry moved or was renamed, so this parser needs updating." + ) + + +def task_kwargs(module_src: str, path: str) -> dict[str, ast.expr]: + """Keyword args of the `super().__init__(...)` call in a task class.""" + tree = ast.parse(module_src) + + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + base_names = {b.id for b in node.bases if isinstance(b, ast.Name)} + if not base_names & TASK_BASES: + continue + for call in ast.walk(node): + if not isinstance(call, ast.Call): + continue + func = call.func + if not isinstance(func, ast.Attribute) or func.attr != "__init__": + continue + return {kw.arg: kw.value for kw in call.keywords if kw.arg} + + raise UpstreamDrift(f"no task class with a `super().__init__(...)` call in {path}") + + +def resolve(node: ast.expr | None, env: dict[str, dict], path: str, field: str): + """Resolve a constructor argument to (env_var_name, value). + + A literal resolves to itself with no env var. A `Name` is an env constant, so + it is looked up in env.template and fails loudly if absent. + """ + if node is None: + return None, ABSENT + + if isinstance(node, ast.Constant): + return None, node.value + + if isinstance(node, ast.Name): + var = node.id + if var not in env: + raise UpstreamDrift( + f"{path} passes {field}={var}, but env.template does not define " + f"{var}. Either the variable was renamed upstream or it is " + f"undocumented, and printing it here would be a guess." + ) + return var, env[var]["default"] + + # Anything else (a call, an f-string, a conditional) is beyond what this + # parser claims to understand, so say so rather than print something wrong. + raise UpstreamDrift( + f"{path} passes a {type(node).__name__} for {field}, which this parser " + f"cannot resolve. Extend resolve() to handle it." + ) + + +def build_row(path: str, kind: str, env: dict[str, dict]) -> dict: + kwargs = task_kwargs(fetch_text(romm_raw_url(path)), path) + + _, title = resolve(kwargs.get("title"), env, path, "title") + _, description = resolve(kwargs.get("description"), env, path, "description") + enable_var, _ = resolve(kwargs.get("enabled"), env, path, "enabled") + cron_var, cron = resolve(kwargs.get("cron_string"), env, path, "cron_string") + + if title in (ABSENT, None, ""): + raise UpstreamDrift(f"{path} has no title= in its constructor") + + if description in (ABSENT, None, ""): + purpose = "-" + else: + purpose = str(description).rstrip(".") + "." + if kind == "Scheduled" and not enable_var: + purpose += " Always on, not configurable." + + return { + "name": str(title), + "type": kind, + "default_cron": str(cron) if cron not in (ABSENT, None, "") else "-", + "enable_var": enable_var or "-", + "env_var": cron_var or "-", + "purpose": purpose, + } + + +def collect(env: dict[str, dict]) -> list[dict]: + scheduled = scheduled_module_paths(fetch_text(romm_raw_url("backend/startup.py"))) + manual = manual_module_paths(fetch_text(romm_raw_url("backend/endpoints/tasks.py"))) + + rows = [build_row(p, "Scheduled", env) for p in scheduled] + # A task in both registries is scheduled and also runnable by hand, so it is + # already listed above. + rows += [build_row(p, "Manual", env) for p in manual if p not in scheduled] + + for w in WATCHERS: + for field in ("enable_var", "env_var"): + if w[field] not in env: + raise UpstreamDrift( + f"watcher {w['name']} references {w[field]}, which env.template " + f"does not define. Update WATCHERS in this script." + ) + rows.append({**w, "type": "Watcher", "default_cron": "-"}) + + return rows + + +def render(rows: Iterable[dict]) -> str: out = [ "", "", "| Task | Type | Default schedule | Enable var | Schedule/delay var | Purpose |", "| --- | --- | --- | --- | --- | --- |", ] - for t in TASKS: + for t in rows: out.append( f"| {t['name']} | {t['type']} | `{t['default_cron']}` " f"| `{t['enable_var']}` | `{t['env_var']}` | {t['purpose']} |" @@ -157,8 +251,27 @@ def render() -> str: def main() -> int: - out = write_snippet("scheduled-tasks.md", render()) - print(f"Wrote {len(TASKS)} tasks to {out}") + env_rows = parse_env_template(fetch_text(romm_raw_url("env.template"))) + env = {r["name"]: r for r in env_rows} + if not env: + print("WARN: no env vars parsed from env.template", file=sys.stderr) + return 1 + + try: + rows = collect(env) + except UpstreamDrift as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + out = write_snippet("scheduled-tasks.md", render(rows)) + counts = { + kind: sum(1 for r in rows if r["type"] == kind) + for kind in ("Scheduled", "Manual", "Watcher") + } + print( + f"Wrote {len(rows)} tasks to {out} ({counts['Scheduled']} scheduled, " + f"{counts['Manual']} manual, {counts['Watcher']} watchers)" + ) return 0 diff --git a/scripts/sources.toml b/scripts/sources.toml index 7c141a45..f63c1bcf 100644 --- a/scripts/sources.toml +++ b/scripts/sources.toml @@ -1,7 +1,13 @@ # Pinned upstream sources for the docs generators. # -# Bumped automatically by .github/workflows/romm-release-bump.yml on each -# rommapp/romm release. Do not hand-edit unless you know why. +# Bumped automatically by .github/workflows/romm-release-bump.yml, which polls +# daily for new rommapp/romm releases and opens a PR with the regenerated +# snippets. Hand-edit only to pin a pre-release or roll back, and re-run the +# generators afterwards: +# +# uv run python -m scripts.gen_env_vars +# uv run python -m scripts.gen_scheduled_tasks +# uv run python -m scripts.gen_platforms [romm] repo = "rommapp/romm"