jobwright 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jobwright/__init__.py +8 -0
- jobwright/_templates/job/claude.md.j2 +25 -0
- jobwright/_templates/job/job_definition.json.j2 +18 -0
- jobwright/_templates/job/notebook_header.py.j2 +17 -0
- jobwright/_templates/job/task_definition.sql.j2 +9 -0
- jobwright/_templates/repo/AGENTS.md.j2 +43 -0
- jobwright/cli.py +336 -0
- jobwright/config.py +304 -0
- jobwright/jobsindex.py +349 -0
- jobwright/platforms/__init__.py +57 -0
- jobwright/platforms/airflow.py +118 -0
- jobwright/platforms/base.py +170 -0
- jobwright/platforms/databricks.py +293 -0
- jobwright/platforms/dbt.py +84 -0
- jobwright/platforms/snowflake_tasks.py +151 -0
- jobwright/policy.py +109 -0
- jobwright/scaffolder.py +96 -0
- jobwright/tools/__init__.py +5 -0
- jobwright/tools/check_dependency_vulns.py +154 -0
- jobwright/tools/check_notebook_syntax.py +71 -0
- jobwright/tools/job_doc_lint.py +88 -0
- jobwright/tools/schema_compliance.py +85 -0
- jobwright/tools/validate_job.py +160 -0
- jobwright/tools/validate_job_definitions.py +62 -0
- jobwright-0.0.1.dist-info/METADATA +110 -0
- jobwright-0.0.1.dist-info/RECORD +29 -0
- jobwright-0.0.1.dist-info/WHEEL +4 -0
- jobwright-0.0.1.dist-info/entry_points.txt +2 -0
- jobwright-0.0.1.dist-info/licenses/LICENSE +21 -0
jobwright/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""jobwright — an AI layer for governing data-orchestration jobs.
|
|
2
|
+
|
|
3
|
+
Intentionally import-light: this module is imported by the stdlib-only
|
|
4
|
+
``deploy_safety`` hook to resolve a platform's destructive-command patterns, so
|
|
5
|
+
it must not pull in ``yaml`` / ``typer`` / other heavy deps at import time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Job: {{ ticket }} {{ name }}
|
|
2
|
+
{% for f in cfg.governance.claude_md_required -%}
|
|
3
|
+
**{{ f }}**: TODO
|
|
4
|
+
{% endfor %}
|
|
5
|
+
## Overview
|
|
6
|
+
TODO — what this job does and why.
|
|
7
|
+
|
|
8
|
+
## Data Sources
|
|
9
|
+
- TODO
|
|
10
|
+
|
|
11
|
+
## Data Outputs
|
|
12
|
+
- TODO
|
|
13
|
+
|
|
14
|
+
## External Integrations
|
|
15
|
+
- TODO (SFTP / SendGrid / Google Sheets / S3 / none)
|
|
16
|
+
|
|
17
|
+
## Architecture Compliance
|
|
18
|
+
- Deprecated-schema references: none
|
|
19
|
+
- Layer: TODO
|
|
20
|
+
|
|
21
|
+
## Known Issues
|
|
22
|
+
- None yet.
|
|
23
|
+
|
|
24
|
+
## Status
|
|
25
|
+
TESTING
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{ folder }}",
|
|
3
|
+
"schedule": {
|
|
4
|
+
"quartz_cron_expression": "0 0 12 * * ?",
|
|
5
|
+
"timezone_id": "America/New_York",
|
|
6
|
+
"pause_status": "PAUSED"
|
|
7
|
+
},
|
|
8
|
+
"max_concurrent_runs": 1,
|
|
9
|
+
"tasks": [
|
|
10
|
+
{
|
|
11
|
+
"task_key": "main",
|
|
12
|
+
"notebook_task": {
|
|
13
|
+
"notebook_path": "{{ cfg.project.jobs_dir }}/{{ folder }}/{{ notebook[:-3] }}"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"format": "MULTI_TASK"
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{% for f in cfg.governance.header_required -%}
|
|
2
|
+
{% if f == "JOB" %}# JOB: {{ ticket }} {{ name }}
|
|
3
|
+
{% elif f == "TICKET" %}# TICKET: {{ ticket }}
|
|
4
|
+
{% elif f == "LAST_UPDATED" %}# LAST_UPDATED: {{ today }}
|
|
5
|
+
{% else %}# {{ f }}: TODO
|
|
6
|
+
{% endif %}{% endfor -%}
|
|
7
|
+
{% if "OWNER" not in cfg.governance.header_required %}# OWNER: TODO
|
|
8
|
+
{% endif -%}
|
|
9
|
+
{% if "LAST_UPDATED" not in cfg.governance.header_required %}# LAST_UPDATED: {{ today }}
|
|
10
|
+
{% endif -%}
|
|
11
|
+
# ARCHITECTURE_COMPLIANCE: deprecated_schema=none
|
|
12
|
+
|
|
13
|
+
"""{{ ticket }} {{ name }}.
|
|
14
|
+
|
|
15
|
+
TODO: describe the job. Scaffolded by jobwright — fill in the header above and the
|
|
16
|
+
companion claude.md before shipping (run `jobwright validate-job` to check).
|
|
17
|
+
"""
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- {{ ticket }} {{ name }} — task definition (scaffolded by jobwright; fill in the TODOs).
|
|
2
|
+
-- sql-ddl deploy model: this DDL is the source of truth. Run `jobwright diff-job` before
|
|
3
|
+
-- applying CREATE OR REPLACE against a live task.
|
|
4
|
+
CREATE OR REPLACE TASK {{ folder }}
|
|
5
|
+
WAREHOUSE = TODO_WAREHOUSE
|
|
6
|
+
SCHEDULE = 'USING CRON 0 12 * * * America/New_York'
|
|
7
|
+
-- SUSPENDED by default; resume explicitly after review.
|
|
8
|
+
AS
|
|
9
|
+
SELECT 1; -- TODO: task body
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# {{ cfg.project.name or "Data Jobs" }} — agent rulebook
|
|
2
|
+
|
|
3
|
+
Generated by [jobwright](https://github.com/kyle-chalmers/jobwright) from
|
|
4
|
+
`jobwright.config.yaml`. Do not hand-edit — re-run `jobwright gen-agents` after
|
|
5
|
+
changing config.
|
|
6
|
+
|
|
7
|
+
## Platform
|
|
8
|
+
|
|
9
|
+
- Orchestrator: **{{ cfg.platform.kind }}** (deploy model: `{{ cfg.platform.deploy_model }}`).
|
|
10
|
+
{% if cfg.platform.deploy_model == "api-reset" -%}
|
|
11
|
+
- Job definitions live in the repo and are pushed to the platform. **Live state can drift from the repo.** Never deploy/reset a job from a repo definition without first running `jobwright diff-job <job>` and confirming the diff is intentional.
|
|
12
|
+
{% elif cfg.platform.deploy_model == "git-sync" -%}
|
|
13
|
+
- Code is the source of truth; deploy is a git push/sync. There is no live-vs-repo drift to reconcile.
|
|
14
|
+
{% else -%}
|
|
15
|
+
- Objects are defined by DDL; live state can drift. Diff before replacing a definition.
|
|
16
|
+
{% endif -%}
|
|
17
|
+
- Destructive platform/warehouse commands are gated by the deploy-safety hook — it will ask before a reset/delete/destructive-SQL. Honor the prompt.
|
|
18
|
+
|
|
19
|
+
## Architecture compliance
|
|
20
|
+
{% if cfg.architecture.layers %}
|
|
21
|
+
Layer-referencing rules (a layer may only reference these):
|
|
22
|
+
|
|
23
|
+
| Layer | May reference |
|
|
24
|
+
|---|---|
|
|
25
|
+
{% for layer in cfg.architecture.layers -%}
|
|
26
|
+
| {{ layer }} | {{ cfg.architecture.layer_rules.get(layer, []) | join(", ") or "—" }} |
|
|
27
|
+
{% endfor %}
|
|
28
|
+
{% endif -%}
|
|
29
|
+
{% if cfg.architecture.deprecated_schema_deny -%}
|
|
30
|
+
- **Deprecated schemas (migrate away):** {{ cfg.architecture.deprecated_schema_deny | join(", ") }}. References are flagged by `jobwright check architecture` as migration debt.
|
|
31
|
+
{% endif %}
|
|
32
|
+
|
|
33
|
+
## Documentation standards
|
|
34
|
+
|
|
35
|
+
Every job folder needs a `claude.md` with: {{ cfg.governance.claude_md_required | join(", ") }}.
|
|
36
|
+
Every notebook needs a header with: {{ cfg.governance.header_required | join(", ") }}.
|
|
37
|
+
|
|
38
|
+
## Workflow
|
|
39
|
+
|
|
40
|
+
- `jobwright jobs-index` — regenerate the catalog; recall prior work before building.
|
|
41
|
+
- `jobwright validate-job <folder>` — the PASS/FAIL gate before a PR.
|
|
42
|
+
- `jobwright check architecture <path>` — compliance / migration scan.
|
|
43
|
+
- `jobwright diff-job <job>` — live-vs-repo drift before any deploy.
|
jobwright/cli.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""jobwright CLI — one implementation, many consumers.
|
|
2
|
+
|
|
3
|
+
Skills, hooks, and CI all call these commands so the logic lives in exactly one
|
|
4
|
+
place. Phase 0 ships: ``doctor``, ``jobs-index`` (build/check), and ``diff-job``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import CONFIG_FILENAME, ConfigError, find_config, load_config
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
add_completion=False,
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
help="Govern, validate, and safely ship data-orchestration jobs with Claude Code.",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_fmt(fmt: str) -> str:
|
|
26
|
+
if fmt not in ("md", "json"):
|
|
27
|
+
typer.secho(f"--format must be 'md' or 'json' (got {fmt!r}).", fg=typer.colors.RED)
|
|
28
|
+
raise typer.Exit(2)
|
|
29
|
+
return fmt
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load():
|
|
33
|
+
"""Load config + return (config, repo_root). Exits cleanly on error."""
|
|
34
|
+
cfg_path = find_config()
|
|
35
|
+
if cfg_path is None:
|
|
36
|
+
typer.secho(
|
|
37
|
+
f"No {CONFIG_FILENAME} found (searched cwd and parents). Run `jobwright init`.",
|
|
38
|
+
fg=typer.colors.RED,
|
|
39
|
+
)
|
|
40
|
+
raise typer.Exit(2)
|
|
41
|
+
try:
|
|
42
|
+
cfg = load_config(cfg_path)
|
|
43
|
+
except ConfigError as exc:
|
|
44
|
+
typer.secho(f"Config error: {exc}", fg=typer.colors.RED)
|
|
45
|
+
raise typer.Exit(2) from None
|
|
46
|
+
return cfg, cfg_path.parent
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def version() -> None:
|
|
51
|
+
"""Print the jobwright version."""
|
|
52
|
+
typer.echo(__version__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def doctor() -> None:
|
|
57
|
+
"""Check config + environment: platform, profile, CLI availability, adapter."""
|
|
58
|
+
cfg_path = find_config()
|
|
59
|
+
if cfg_path is None:
|
|
60
|
+
typer.secho(f"✗ no {CONFIG_FILENAME} found — run `jobwright init`.", fg=typer.colors.RED)
|
|
61
|
+
raise typer.Exit(1)
|
|
62
|
+
typer.secho(f"✓ config: {cfg_path}", fg=typer.colors.GREEN)
|
|
63
|
+
try:
|
|
64
|
+
cfg = load_config(cfg_path)
|
|
65
|
+
except ConfigError as exc:
|
|
66
|
+
typer.secho(f"✗ config invalid: {exc}", fg=typer.colors.RED)
|
|
67
|
+
raise typer.Exit(1) from None
|
|
68
|
+
|
|
69
|
+
typer.echo(f" platform.kind = {cfg.platform.kind}")
|
|
70
|
+
typer.echo(f" platform.profile = {cfg.platform.profile or '(none)'}")
|
|
71
|
+
typer.echo(f" deploy_model = {cfg.platform.deploy_model}")
|
|
72
|
+
typer.echo(f" warehouse.dialect = {cfg.warehouse.dialect}")
|
|
73
|
+
typer.echo(f" jobs_dir = {cfg.project.jobs_dir}")
|
|
74
|
+
typer.echo(f" key_prefixes = {', '.join(cfg.project.key_prefixes) or '(none)'}")
|
|
75
|
+
typer.echo(f" deprecated_deny = {', '.join(cfg.architecture.deprecated_schema_deny) or '(none)'}")
|
|
76
|
+
|
|
77
|
+
ok = True
|
|
78
|
+
try:
|
|
79
|
+
from .platforms import adapter_kinds, get_adapter_class
|
|
80
|
+
|
|
81
|
+
if cfg.platform.kind in adapter_kinds():
|
|
82
|
+
cls = get_adapter_class(cfg.platform.kind)
|
|
83
|
+
typer.secho(f"✓ adapter: {cls.__name__} (deploy_model={cls.deploy_model})", fg=typer.colors.GREEN)
|
|
84
|
+
if cls.deploy_model != cfg.platform.deploy_model:
|
|
85
|
+
typer.secho(
|
|
86
|
+
f"✗ deploy_model mismatch: config says '{cfg.platform.deploy_model}' but the "
|
|
87
|
+
f"{cfg.platform.kind} adapter is '{cls.deploy_model}'. Fix config — a wrong "
|
|
88
|
+
"deploy_model can disable drift detection.",
|
|
89
|
+
fg=typer.colors.RED,
|
|
90
|
+
)
|
|
91
|
+
ok = False
|
|
92
|
+
else:
|
|
93
|
+
typer.secho(
|
|
94
|
+
f"✗ no adapter registered for '{cfg.platform.kind}' (have: {adapter_kinds()})",
|
|
95
|
+
fg=typer.colors.RED,
|
|
96
|
+
)
|
|
97
|
+
ok = False
|
|
98
|
+
except Exception as exc: # pragma: no cover
|
|
99
|
+
typer.secho(f"✗ adapter registry error: {exc}", fg=typer.colors.RED)
|
|
100
|
+
ok = False
|
|
101
|
+
|
|
102
|
+
# Probe likely CLIs for this platform (advisory only).
|
|
103
|
+
probe = {"databricks": ["databricks"], "airflow": ["airflow"], "dbt": ["dbt"],
|
|
104
|
+
"prefect": ["prefect"], "snowflake_tasks": ["snow"]}.get(cfg.platform.kind, [])
|
|
105
|
+
for binary in probe:
|
|
106
|
+
if shutil.which(binary):
|
|
107
|
+
typer.secho(f"✓ `{binary}` on PATH", fg=typer.colors.GREEN)
|
|
108
|
+
else:
|
|
109
|
+
typer.secho(f" (note) `{binary}` not on PATH — live verbs (diff/run) will be unavailable", fg=typer.colors.YELLOW)
|
|
110
|
+
|
|
111
|
+
raise typer.Exit(0 if ok else 1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command("jobs-index")
|
|
115
|
+
def jobs_index(
|
|
116
|
+
check: bool = typer.Option(False, "--check", help="exit 1 if JOBS.md/OBJECTS.md are stale (CI gate)"),
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Render <jobs_dir>/JOBS.md + OBJECTS.md (deterministic; --check for a CI gate)."""
|
|
119
|
+
from .jobsindex import render_all, settings_from_config
|
|
120
|
+
|
|
121
|
+
cfg, root = _load()
|
|
122
|
+
fresh = render_all(root, settings_from_config(cfg))
|
|
123
|
+
|
|
124
|
+
if check:
|
|
125
|
+
stale = [p.name for p, txt in fresh.items() if (p.read_text() if p.is_file() else None) != txt]
|
|
126
|
+
if stale:
|
|
127
|
+
typer.secho(
|
|
128
|
+
f"stale: {', '.join(stale)} — run `jobwright jobs-index`.", fg=typer.colors.RED, err=True
|
|
129
|
+
)
|
|
130
|
+
raise typer.Exit(1)
|
|
131
|
+
typer.secho("JOBS.md + OBJECTS.md are up to date.", fg=typer.colors.GREEN)
|
|
132
|
+
raise typer.Exit(0)
|
|
133
|
+
|
|
134
|
+
for p, txt in fresh.items():
|
|
135
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
p.write_bytes(txt.encode("utf-8"))
|
|
137
|
+
n_jobs = sum(1 for line in next(iter(fresh.values())).splitlines() if line.startswith("| ["))
|
|
138
|
+
typer.secho(f"Wrote {' + '.join(p.name for p in fresh)} ({n_jobs} jobs).", fg=typer.colors.GREEN)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command("diff-job")
|
|
142
|
+
def diff_job(
|
|
143
|
+
ref: str = typer.Argument(..., help="ticket / job name / folder (e.g. BI-813 or BI-813_Remitter)"),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Diff the LIVE job definition against the repo JSON (drift detection)."""
|
|
146
|
+
from .platforms import get_adapter
|
|
147
|
+
|
|
148
|
+
cfg, _ = _load()
|
|
149
|
+
adapter = get_adapter(cfg.platform.kind, profile=cfg.platform.profile, config=cfg)
|
|
150
|
+
# Gate on the ADAPTER's deploy_model (authoritative), not config's — a config typo
|
|
151
|
+
# to git-sync must not silently disable live-vs-repo drift detection.
|
|
152
|
+
if adapter.deploy_model == "git-sync":
|
|
153
|
+
typer.secho(
|
|
154
|
+
f"platform '{adapter.kind}' is git-sync — code is the source of truth, so there is "
|
|
155
|
+
"no live-vs-repo drift. Use `git status` / `git diff` instead.",
|
|
156
|
+
fg=typer.colors.YELLOW,
|
|
157
|
+
)
|
|
158
|
+
raise typer.Exit(0)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
result = adapter.diff_live_vs_repo(ref)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
typer.secho(f"diff failed: {exc}", fg=typer.colors.RED, err=True)
|
|
164
|
+
raise typer.Exit(2) from None
|
|
165
|
+
|
|
166
|
+
if not result.drift:
|
|
167
|
+
typer.secho(f"✓ no drift for {ref} — live matches repo.", fg=typer.colors.GREEN)
|
|
168
|
+
raise typer.Exit(0)
|
|
169
|
+
|
|
170
|
+
typer.secho(f"⚠ DRIFT detected for {ref}:", fg=typer.colors.YELLOW)
|
|
171
|
+
for key in result.changed:
|
|
172
|
+
d = result.detail.get(key, {})
|
|
173
|
+
typer.echo(f" ~ {key}\n repo: {d.get('repo')}\n live: {d.get('live')}")
|
|
174
|
+
for key in result.added:
|
|
175
|
+
typer.echo(f" + {key} (live only): {result.detail.get(key, {}).get('live')}")
|
|
176
|
+
for key in result.removed:
|
|
177
|
+
typer.echo(f" - {key} (repo only): {result.detail.get(key, {}).get('repo')}")
|
|
178
|
+
typer.secho(
|
|
179
|
+
"\nThe repo JSON does NOT match live. Do not `databricks jobs reset` from it without "
|
|
180
|
+
"reconciling — that would overwrite live state.",
|
|
181
|
+
fg=typer.colors.RED,
|
|
182
|
+
)
|
|
183
|
+
raise typer.Exit(1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
_STARTER_CONFIG = """\
|
|
187
|
+
schema_version: 1
|
|
188
|
+
project:
|
|
189
|
+
name: "My Data Jobs"
|
|
190
|
+
key_prefixes: ["JOB"]
|
|
191
|
+
jobs_dir: jobs
|
|
192
|
+
platform:
|
|
193
|
+
kind: databricks # databricks | airflow | dbt | snowflake_tasks | ...
|
|
194
|
+
profile: prod
|
|
195
|
+
deploy_model: api-reset # api-reset | git-sync | sql-ddl
|
|
196
|
+
job_def_dirs:
|
|
197
|
+
dev: databricks/job_definitions/dev
|
|
198
|
+
prod: databricks/job_definitions/prod
|
|
199
|
+
warehouse:
|
|
200
|
+
dialect: snowflake
|
|
201
|
+
architecture:
|
|
202
|
+
layers: [RAW, STAGING, ANALYTICS, REPORTING]
|
|
203
|
+
layer_rules:
|
|
204
|
+
STAGING: [RAW, STAGING]
|
|
205
|
+
ANALYTICS: [STAGING, ANALYTICS]
|
|
206
|
+
REPORTING: [STAGING, ANALYTICS, REPORTING]
|
|
207
|
+
deprecated_schema_deny: []
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command()
|
|
212
|
+
def init() -> None:
|
|
213
|
+
"""Write a starter jobwright.config.yaml in the current directory (if absent)."""
|
|
214
|
+
if find_config() is not None:
|
|
215
|
+
typer.secho(f"{CONFIG_FILENAME} already exists — nothing to do.", fg=typer.colors.YELLOW)
|
|
216
|
+
raise typer.Exit(0)
|
|
217
|
+
from pathlib import Path as _P
|
|
218
|
+
|
|
219
|
+
_P(CONFIG_FILENAME).write_text(_STARTER_CONFIG)
|
|
220
|
+
typer.secho(f"Wrote {CONFIG_FILENAME}. Edit it, then run `jobwright doctor`.", fg=typer.colors.GREEN)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command("new-job")
|
|
224
|
+
def new_job_cmd(
|
|
225
|
+
ticket: str = typer.Argument(..., help="ticket key, e.g. BI-1234"),
|
|
226
|
+
name: str = typer.Argument(..., help="human job name, e.g. 'Outbound List Generation'"),
|
|
227
|
+
force: bool = typer.Option(False, "--force", help="overwrite existing files"),
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Scaffold a governed job folder (claude.md + notebook header + paused def stub)."""
|
|
230
|
+
from datetime import date
|
|
231
|
+
|
|
232
|
+
from .scaffolder import new_job
|
|
233
|
+
|
|
234
|
+
cfg, root = _load()
|
|
235
|
+
res = new_job(cfg, root, ticket, name, today=date.today().isoformat(), force=force)
|
|
236
|
+
for p in res.created:
|
|
237
|
+
typer.secho(f" + {p.relative_to(root)}", fg=typer.colors.GREEN)
|
|
238
|
+
for p in res.skipped:
|
|
239
|
+
typer.secho(f" · {p.relative_to(root)} (exists; --force to overwrite)", fg=typer.colors.YELLOW)
|
|
240
|
+
typer.echo(f"\nNext: fill the TODOs, then `jobwright validate-job {res.job_dir.relative_to(root)}`.")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.command("gen-agents")
|
|
244
|
+
def gen_agents_cmd(
|
|
245
|
+
output: str = typer.Option("AGENTS.jobwright.md", "--output", "-o", help="output path (relative to repo root)"),
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Render an AGENTS.md rulebook from config (the generated rulebook)."""
|
|
248
|
+
from .scaffolder import render_agents_md
|
|
249
|
+
|
|
250
|
+
cfg, root = _load()
|
|
251
|
+
out = (root / output).resolve()
|
|
252
|
+
try:
|
|
253
|
+
out.relative_to(root.resolve())
|
|
254
|
+
except ValueError:
|
|
255
|
+
typer.secho(f"--output must stay within the repo (got {output!r}).", fg=typer.colors.RED)
|
|
256
|
+
raise typer.Exit(2) from None
|
|
257
|
+
out.write_text(render_agents_md(cfg))
|
|
258
|
+
typer.secho(f"Wrote {out.relative_to(root.resolve())} from jobwright.config.yaml.", fg=typer.colors.GREEN)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
check_app = typer.Typer(no_args_is_help=True, help="Run a single generic check (file-based; no platform calls).")
|
|
262
|
+
app.add_typer(check_app, name="check")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@check_app.command("architecture")
|
|
266
|
+
def check_architecture(
|
|
267
|
+
paths: list[str] = typer.Argument(..., help="files or dirs to scan"),
|
|
268
|
+
fmt: str = typer.Option("md", "--format", help="md|json"),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Scan for deprecated-schema references and layer-rule violations."""
|
|
271
|
+
from .tools import schema_compliance
|
|
272
|
+
|
|
273
|
+
raise typer.Exit(schema_compliance.main(["--format", _check_fmt(fmt), *paths]))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@check_app.command("docs")
|
|
277
|
+
def check_docs(
|
|
278
|
+
job_dirs: list[str] = typer.Argument(..., help="job folders to lint"),
|
|
279
|
+
fmt: str = typer.Option("md", "--format", help="md|json"),
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Lint claude.md + notebook-header completeness against governance config."""
|
|
282
|
+
from .tools import job_doc_lint
|
|
283
|
+
|
|
284
|
+
raise typer.Exit(job_doc_lint.main(["--format", _check_fmt(fmt), *job_dirs]))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@check_app.command("syntax")
|
|
288
|
+
def check_syntax(files: list[str] = typer.Argument(..., help="notebook .py files")) -> None:
|
|
289
|
+
"""Magic-aware Python syntax check."""
|
|
290
|
+
from .tools import check_notebook_syntax
|
|
291
|
+
|
|
292
|
+
raise typer.Exit(check_notebook_syntax.main(files))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@check_app.command("job-defs")
|
|
296
|
+
def check_job_defs(files: list[str] = typer.Argument(..., help="job-definition JSON files")) -> None:
|
|
297
|
+
"""Validate job-definition JSON (parse + name presence in deployable dirs)."""
|
|
298
|
+
from .tools import validate_job_definitions
|
|
299
|
+
|
|
300
|
+
raise typer.Exit(validate_job_definitions.main(files))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@check_app.command("deps")
|
|
304
|
+
def check_deps(files: list[str] = typer.Argument(..., help="notebook .py files with %pip install pins")) -> None:
|
|
305
|
+
"""OSV vulnerability lookup on pinned %pip install packages."""
|
|
306
|
+
from .tools import check_dependency_vulns
|
|
307
|
+
|
|
308
|
+
raise typer.Exit(check_dependency_vulns.main(files))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.command("validate-job")
|
|
312
|
+
def validate_job_cmd(
|
|
313
|
+
job_dir: str = typer.Argument(..., help="a job folder, e.g. jobs/BI-813_Remitter"),
|
|
314
|
+
offline: bool = typer.Option(False, "--offline", help="skip the network dependency-vuln check"),
|
|
315
|
+
fmt: str = typer.Option("md", "--format", help="md|json"),
|
|
316
|
+
) -> None:
|
|
317
|
+
"""Composite PASS/FAIL gate for one job (syntax + job-defs + deps + architecture + docs)."""
|
|
318
|
+
from .tools import validate_job as vj
|
|
319
|
+
|
|
320
|
+
_check_fmt(fmt)
|
|
321
|
+
cfg, root = _load()
|
|
322
|
+
result = vj.validate(Path(job_dir), cfg, offline=offline, root=root)
|
|
323
|
+
if fmt == "json":
|
|
324
|
+
typer.echo(__import__("json").dumps(result, indent=2))
|
|
325
|
+
else:
|
|
326
|
+
typer.secho(vj.render_md(result), fg=(typer.colors.GREEN if result["ok"] else typer.colors.RED))
|
|
327
|
+
raise typer.Exit(0 if result["ok"] else 1)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def main() -> int: # console-script-friendly entry
|
|
331
|
+
app()
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
sys.exit(main())
|