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 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())