ai-push-hooks 0.3.1 → 0.3.3

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/README.md +90 -82
  3. package/SECURITY.md +7 -2
  4. package/ai-push-hooks.toml +1 -1
  5. package/docs/configuration.md +339 -0
  6. package/package.json +3 -3
  7. package/pyproject.toml +12 -3
  8. package/src/ai_push_hooks/artifacts.py +19 -6
  9. package/src/ai_push_hooks/cli.py +24 -8
  10. package/src/ai_push_hooks/config.py +220 -49
  11. package/src/ai_push_hooks/engine.py +93 -43
  12. package/src/ai_push_hooks/executors/apply.py +121 -43
  13. package/src/ai_push_hooks/executors/ask.py +30 -13
  14. package/src/ai_push_hooks/executors/exec.py +61 -22
  15. package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
  16. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  17. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  18. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  19. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  20. package/src/ai_push_hooks/executors/runners/opencode.py +74 -21
  21. package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
  22. package/src/ai_push_hooks/executors/runners/process.py +26 -7
  23. package/src/ai_push_hooks/executors/runners/registry.py +14 -5
  24. package/src/ai_push_hooks/executors/step_commands.py +58 -18
  25. package/src/ai_push_hooks/git_utils.py +92 -27
  26. package/src/ai_push_hooks/hook.py +48 -12
  27. package/src/ai_push_hooks/install.py +40 -18
  28. package/src/ai_push_hooks/modules/beads.py +18 -7
  29. package/src/ai_push_hooks/modules/docs.py +17 -7
  30. package/src/ai_push_hooks/modules/pr.py +18 -7
  31. package/src/ai_push_hooks/paths.py +6 -2
  32. package/src/ai_push_hooks/plugin_loader.py +79 -26
  33. package/src/ai_push_hooks/plugins.py +3 -1
  34. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  35. package/src/ai_push_hooks/types.py +47 -27
  36. package/run.sh +0 -29
@@ -0,0 +1,339 @@
1
+ # Configuration
2
+
3
+ Define workflows in `ai-push-hooks.toml` at the repository root. Start with the [README example](../README.md#example-check-your-rules) or the [starter configuration](../ai-push-hooks.toml).
4
+
5
+ ## Installation
6
+
7
+ The [npm quick start](../README.md#quick-start) installs a repository-local wrapper. It needs Node 18+ and Python 3.10-3.13 on the hook's `PATH`; the npm package does not bundle Python.
8
+
9
+ For pnpm:
10
+
11
+ ```bash
12
+ pnpm add -D ai-push-hooks@beta
13
+ pnpm exec ai-push-hooks init --template minimal-docs
14
+ pnpm exec ai-push-hooks install
15
+ ```
16
+
17
+ For a Python-only installation:
18
+
19
+ ```bash
20
+ python -m pip install ai-push-hooks
21
+ ai-push-hooks init --template minimal-docs
22
+ ai-push-hooks install
23
+ ```
24
+
25
+ `uv tool install ai-push-hooks` or `pipx install ai-push-hooks` can replace the pip command. npm uses the `beta` tag while the package is in beta.
26
+
27
+ AI steps need the selected CLI installed and authenticated with an available model. Deterministic-only workflows do not need one. `init` and `install` refuse to overwrite existing files; `--force` explicitly replaces them.
28
+
29
+ ## Modules And Steps
30
+
31
+ `[workflow].modules` selects the modules to run. Define each module under `modules.<name>` with a non-empty list of steps. Modules are enabled by default; set `enabled = false` to disable one.
32
+
33
+ Step inputs refer to earlier artifacts in the same module: `collect/push.diff`, for example. Cross-module inputs are not supported. Independent `collect` and `ask` work may run concurrently; built-in `exec`, `assert`, and `apply` steps are serialized. Trusted custom command runners, including project-access `ask` commands, are not enforced read-only.
34
+
35
+ | Field | Used by | Meaning |
36
+ | --- | --- | --- |
37
+ | `id` | All | Unique step name within the module. |
38
+ | `type` | All | `collect`, `ask`, `apply`, `exec`, or `assert`. |
39
+ | `inputs` | Non-collector steps | Earlier module-local artifacts. |
40
+ | `when_env` | All | Run only when the named environment variable is true. |
41
+ | `collector` | `collect` | Built-in collector name. |
42
+ | `prompt` | `ask`, `apply` | Inline instructions. |
43
+ | `prompt_file` | `ask`, `apply` | Repository-relative prompt file. |
44
+ | `fallback_prompt_id` | `ask`, `apply` | Built-in prompt name. |
45
+ | `runner` | `ask`, `apply` | Override the default runner profile. |
46
+ | `output` | `ask` | Required response artifact filename. |
47
+ | `schema` | `ask` | Optional JSON schema name. Omit for plain text. |
48
+ | `allow_paths` | `apply` | Required list of permitted edit globs. |
49
+ | `executor` | `exec` | Built-in action name. |
50
+ | `assertion` | `assert` | Built-in assertion name. |
51
+ | `python` | `collect`, `exec`, `assert` | Repository-local callback reference. |
52
+ | `options` | Python steps | JSON-compatible callback options. |
53
+ | `command` | `exec`, `assert` | Direct argv command. |
54
+ | `stdin` | Command steps | One declared input to send on stdin. |
55
+ | `timeout_seconds` | Command steps | Positive timeout; default `60`. |
56
+
57
+ Choose one handler per deterministic step: a built-in, a callback, or a command. Prompt resolution uses `prompt`, then `prompt_file`, then `fallback_prompt_id`.
58
+
59
+ `ask` produces a response, not an automatic policy verdict. Use `assert` to evaluate findings when they should block. `apply` can run without a preceding `ask`; an input ending in `issues.json` containing `[]` skips the apply step.
60
+
61
+ Upgrading from 0.2.1? Rename `type = "llm"` steps to `type = "ask"`.
62
+
63
+ ## Runner Profiles
64
+
65
+ `[llm].runner` selects the default; a step's `runner` overrides it. Referenced profiles must exist under `[runners.<name>]`, except for the implicit OpenCode default.
66
+
67
+ OpenCode, Codex, and Claude are first-class adapters. The generic `command` type is for other agentic CLIs and follows the custom runner contract below.
68
+
69
+ | Profile field | Values / behavior |
70
+ | --- | --- |
71
+ | `type` | Required: `opencode`, `codex`, `claude`, or `command`. |
72
+ | `model` | Runner-specific model identifier. |
73
+ | `project_access` | `artifacts` or `project`; OpenCode defaults to `artifacts`, others to `project`. |
74
+ | `variant` | Optional OpenCode variant. |
75
+ | `command` | Required argv array for command runners. |
76
+ | `prompt_transport` | Command runners: `stdin` (default) or `argv`. |
77
+
78
+ Without an explicit OpenCode profile, `[llm].model` and `[llm].variant` configure it. Explicit profiles use their own model settings. `AI_PUSH_HOOKS_MODEL` overrides the selected profile's model.
79
+
80
+ Artifact mode supplies collected inputs in a scratch directory. Project mode lets analysis inspect the checkout. Apply always uses a temporary staging copy, with propagation limited by `allow_paths`.
81
+
82
+ OpenCode runs with isolated configuration and permissions; project/global configuration, external plugins, and MCP servers are not loaded. Codex uses read-only analysis and workspace-write apply modes. Claude Code uses separate analysis and editing permissions. Authenticate the CLI before using it in a hook.
83
+
84
+ Apply requires a single pushed branch whose local commit is the checked-out `HEAD`. Staging excludes Git metadata, `AGENTS.md`, ignored files, symlinks, and special files. These controls are not an OS sandbox or an automatic rollback system. See [Security](../SECURITY.md).
85
+
86
+ ### Live validation snapshot
87
+
88
+ Live read/review and apply checks on Linux:
89
+
90
+ | Date | Runner (CLI; model) | Review | Apply |
91
+ | --- | --- | --- | --- |
92
+ | 2026-09-12 | OpenCode (1.18.29; `openai/gpt-5.6-luna`) | Passed | Passed |
93
+ | 2026-09-12 | Claude Code (2.1.220; `sonnet`) | Passed | Passed |
94
+ | 2026-09-14 | Codex (0.152.0; `gpt-5.6-luna`) | Passed | Passed |
95
+
96
+ The Codex check read a random value from a disposable repository, then verified
97
+ that only the allowlisted README changed. These checks establish the tested
98
+ operations, not compatibility with every model or platform.
99
+
100
+ #### Local troubleshooting: Codex sandbox
101
+
102
+ For Codex 0.152, the standalone no-AI sandbox syntax is
103
+ `codex sandbox -- /usr/bin/true`. Resolve host restrictions rather than disabling
104
+ protections, and use a deterministic postcondition to verify the desired checkout
105
+ outcome after an apply.
106
+
107
+ ### Apply and manual commits
108
+
109
+ `apply` is generic: it can edit any eligible checkout file matching `allow_paths`; it is not limited to Markdown. The runner edits a temporary staging copy, and only validated changes propagate back to the checkout. Those edits do not enter the commit already being pushed, and `apply` never creates a Git commit.
110
+
111
+ The existing `docs_apply_requires_manual_commit` assertion is a workflow gate, not human-review enforcement. It prevents the original push from passing after `apply` changes files; it cannot prove that anyone reviewed the edits or that they conform to policy. Add it after the `apply` step when that gate is desired:
112
+
113
+ ```toml
114
+ [[modules.docs.steps]]
115
+ id = "manual-commit"
116
+ type = "assert"
117
+ assertion = "docs_apply_requires_manual_commit"
118
+ inputs = ["apply/result.json"]
119
+ ```
120
+
121
+ The assertion checks `apply/result.json`'s `changed_files` and intentionally blocks when edits were propagated. Review `git diff`, run the relevant checks, commit the approved changes, and retry the push. On the retry, the assertion passes when the apply step reports no changes.
122
+
123
+ ### Deterministic postconditions after apply
124
+
125
+ An apply process succeeding, or reporting `changed_files = []`, is not proof that
126
+ the requested result is present. The latter can simply mean that the apply was a
127
+ legitimate no-op because the checkout was already correct. Add a deterministic
128
+ postcondition after `apply` and before the manual-commit gate when the desired
129
+ file content has a precise representation:
130
+
131
+ ```toml
132
+ [general]
133
+ require_clean_worktree = true
134
+
135
+ [workflow]
136
+ modules = ["docs"]
137
+
138
+ [modules.docs]
139
+ enabled = true
140
+
141
+ [[modules.docs.steps]]
142
+ id = "apply"
143
+ type = "apply"
144
+ prompt = "In the existing README.md, replace 'Release note: DRAFT.' with 'Release note: READY.' and make no other changes."
145
+ allow_paths = ["README.md"]
146
+
147
+ [[modules.docs.steps]]
148
+ id = "postcondition"
149
+ type = "assert"
150
+ command = [
151
+ "{python}",
152
+ "-c",
153
+ "import pathlib, sys; sys.exit(0 if pathlib.Path('README.md').read_text(encoding='utf-8') == 'Release note: READY.\\n' else 1)",
154
+ ]
155
+ inputs = ["apply/result.json"]
156
+
157
+ [[modules.docs.steps]]
158
+ id = "manual-commit"
159
+ type = "assert"
160
+ assertion = "docs_apply_requires_manual_commit"
161
+ inputs = ["apply/result.json"]
162
+ ```
163
+
164
+ This is an intentional synthetic example, not a recommendation to overwrite a
165
+ real README: its fixture starts with exactly `Release note: DRAFT.\n`, and its
166
+ desired full content is exactly `Release note: READY.\n`.
167
+ The command is a direct argv vector rather than a Python `assert`; Python
168
+ optimization must not be able to remove the check. The postcondition reads the
169
+ checkout after apply, not the commit being pushed. Keep the clean-worktree
170
+ workflow setting for the hook's starting state and the manual-commit gate for
171
+ the intentionally dirty post-apply checkout: review the resulting diff, run
172
+ relevant checks, commit it, and retry the push. This gate and postcondition
173
+ still do not prove human review, semantic correctness, or that an agent
174
+ complied with every instruction.
175
+
176
+ ## Custom Runners
177
+
178
+ Use a `command` profile to invoke any other agentic CLI, including Pi, directly or through a thin wrapper. A wrapper can normalize JSONL events into a final response. The runner must be noninteractive: read the prompt from stdin or argv, write only the final response to stdout, and use exit codes to report success or failure.
179
+
180
+ ```toml
181
+ [runners.custom]
182
+ type = "command"
183
+ command = ["/absolute/path/to/scripts/review-agent"]
184
+ prompt_transport = "stdin"
185
+ project_access = "project"
186
+ ```
187
+
188
+ Select it with `runner = "custom"` on an `ask` or `apply` step. The runner receives the full instruction and artifact packet. For apply, its working directory is the staging copy.
189
+
190
+ Commands are argv arrays, not shell strings. Whole-argument placeholders are `{model}`, `{cwd}`, `{stage}`, and `{prompt}`. The `argv` transport requires exactly one `{prompt}` argument; stdin avoids exposing prompts in process listings. Custom programs inherit the user's environment, including authentication variables, and manage their own permissions and session setup/cleanup. For apply, the working directory is the staging copy.
191
+
192
+ ## Commands And Callbacks
193
+
194
+ Command steps run in the real repository, with no implicit shell. Exit zero means success; a nonzero exit blocks the workflow. stdout, stderr, and a `result.json` report are saved as step artifacts.
195
+
196
+ ```toml
197
+ [[modules.verify.steps]]
198
+ id = "tests"
199
+ type = "exec"
200
+ command = ["{python}", "-m", "pytest", "-q"]
201
+ timeout_seconds = 300
202
+ ```
203
+
204
+ Available whole-argument placeholders are `{repo}`, `{python}`, and `{input:<step/artifact>}`. Input placeholders must also appear in `inputs`. Stdin is closed unless `stdin` names a declared input. Command output is bounded to 16 MiB per stream.
205
+
206
+ For custom context or policy, reference a top-level synchronous Python function:
207
+
208
+ ```toml
209
+ [[modules.policy.steps]]
210
+ id = "change-size"
211
+ type = "assert"
212
+ python = "checks/hooks.py:check_size"
213
+ options = { max_files = 25 }
214
+ ```
215
+
216
+ In `checks/hooks.py`:
217
+
218
+ ```python
219
+ from ai_push_hooks.plugins import PluginContext
220
+
221
+
222
+ def check_size(context: PluginContext) -> dict:
223
+ ok = len(context.push.changed_files) <= context.options["max_files"]
224
+ return {"ok": ok, "message": "Change exceeds the configured file limit." if not ok else ""}
225
+ ```
226
+
227
+ Add `policy` to `[workflow].modules` to enable it. Callbacks receive repository and step identifiers, push facts, input paths, options, prior module metadata, and a logger.
228
+
229
+ | Callback type | Return value |
230
+ | --- | --- |
231
+ | `collect` | `CollectorResult` from `ai_push_hooks.plugins`, with artifacts and metadata. |
232
+ | `exec` | JSON-serializable dictionary, saved as `result.json`. |
233
+ | `assert` | Dictionary with boolean `ok` and optional string `message`; false blocks. |
234
+
235
+ Callbacks run as trusted in-process code with already-installed dependencies. There is no callback timeout or automatic dependency installation. Collectors may run concurrently, so custom collectors must be concurrency-safe.
236
+
237
+ ## Built-In Handlers
238
+
239
+ | Kind | Name | Purpose |
240
+ | --- | --- | --- |
241
+ | Collector | `docs_context` | Push diff, changed files, docs excerpts, recent commits. |
242
+ | Collector | `beads_status_context` | Branch and Beads task context. |
243
+ | Collector | `pr_context` | Context for a pull request. |
244
+ | Action | `beads_alignment` | Apply supported `bd update` / `bd close` operations. |
245
+ | Action | `gh_pr_create` | Create or reuse a PR through `gh`. |
246
+ | Assertion | `docs_apply_requires_manual_commit` | Block after applied docs changes. |
247
+ | Assertion | `beads_alignment_clean` | Block on unresolved task alignment. |
248
+
249
+ Beads steps need the native `bd` CLI; PR creation needs `gh`. Gate optional actions with `when_env = "AI_PUSH_HOOKS_CREATE_PR"`, for example. Beads schema and database maintenance remain separate from hook execution.
250
+
251
+ | JSON schema | Payload |
252
+ | --- | --- |
253
+ | `string_array` | Array of strings. |
254
+ | `docs_issue_array` | Array of objects with `file` and `description`. |
255
+ | `beads_alignment_result` | Object with optional `commands` string array. |
256
+ | `pr_create_payload` | Object containing PR creation fields. |
257
+
258
+ Built-in prompts: `docs-query-basic`, `docs-analysis-basic`, `docs-apply-basic`, `beads-plan-basic`, and `pr-compose-basic`.
259
+
260
+ ## Settings
261
+
262
+ These optional settings supplement the workflow and runner definitions:
263
+
264
+ | Section | Key | Default |
265
+ | --- | --- | --- |
266
+ | `general` | `enabled` | `true` |
267
+ | `general` | `allow_push_on_error` | `false` |
268
+ | `general` | `require_clean_worktree` | `false` |
269
+ | `general` | `skip_on_sync_branch` | `true` |
270
+ | `general` | `base_branch` | `"main"` |
271
+ | `llm` | `runner` | `"opencode"` |
272
+ | `llm` | `model` | `"openai/gpt-5.6-luna"` (implicit OpenCode profile) |
273
+ | `llm` | `variant` | `""` |
274
+ | `llm` | `timeout_seconds` | `800` |
275
+ | `llm` | `max_parallel` | `2` |
276
+ | `llm` | `json_max_retries` | `2` |
277
+ | `llm` | `invalid_json_feedback_max_chars` | `6000` |
278
+ | `llm` | `json_retry_new_session` | `true` |
279
+ | `llm` | `delete_session_after_run` | `true` |
280
+ | `llm` | `max_diff_bytes` | `180000` |
281
+ | `llm` | `session_title_prefix` | `"ai-push-hooks"` |
282
+ | `logging` | `level` | `"status"` (`info` and `debug` also available) |
283
+ | `logging` | `jsonl` | `true` |
284
+ | `logging` | `capture_llm_transcript` | `true` (OpenCode only) |
285
+ | `logging` | `print_llm_output` | `false` |
286
+ | `logging` | `dir` | `".git/ai-push-hooks/logs"` |
287
+ | `logging` | `transcript_dir` | `".git/ai-push-hooks/transcripts"` |
288
+ | `logging` | `summary_dir` | `".git/ai-push-hooks/summaries"` |
289
+
290
+ Model availability depends on the provider. Select one available to your account rather than assuming the starter's model is accessible.
291
+
292
+ ## Environment Overrides
293
+
294
+ | Variable | Effect |
295
+ | --- | --- |
296
+ | `AI_PUSH_HOOKS_SKIP=1` | Skip the hook before loading configuration. |
297
+ | `AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR=1` | Log errors but allow the push. |
298
+ | `AI_PUSH_HOOKS_REQUIRE_CLEAN=1` | Require a clean worktree. |
299
+ | `AI_PUSH_HOOKS_ALLOW_DIRTY=1` | Allow a dirty worktree. |
300
+ | `AI_PUSH_HOOKS_BASE_BRANCH` | Override the base branch. |
301
+ | `AI_PUSH_HOOKS_MODEL` | Override the selected runner's model. |
302
+ | `AI_PUSH_HOOKS_VARIANT` | Override the OpenCode variant. |
303
+ | `AI_PUSH_HOOKS_TIMEOUT_SECONDS` | Override the runner timeout. |
304
+ | `AI_PUSH_HOOKS_LOG_LEVEL` | Set console verbosity. |
305
+ | `AI_PUSH_HOOKS_PRINT_LLM_OUTPUT=1` | Print normalized, redacted final responses; sensitive opt-in. |
306
+
307
+ Boolean values accept `1/0`, `true/false`, `yes/no`, `y/n`, and `on/off`. Skips and fail-open settings are explicit policy choices, not successful checks.
308
+
309
+ ## Hook Managers
310
+
311
+ The built-in `install` command creates a repository-local pre-push hook without changing Git configuration. Existing hooks require explicit `--force` replacement; shared/external paths and symlinks are refused. Keep the installed Python executable or local npm package available at its installed location.
312
+
313
+ For Lefthook, use this in `lefthook.yml` instead of the built-in installer:
314
+
315
+ ```yaml
316
+ pre-push:
317
+ commands:
318
+ ai-checks:
319
+ run: npx --no-install ai-push-hooks hook {1} {2}
320
+ use_stdin: true
321
+ ```
322
+
323
+ Run `lefthook install`. For a Python installation, omit the `npx --no-install` prefix. Other hook managers must likewise forward Git's remote arguments, ref-update stdin, and exit status. If earlier commands consume stdin, capture it first and replay it to ai-push-hooks.
324
+
325
+ To remove the generated hook, inspect `git rev-parse --git-path hooks/pre-push` and remove only the ai-push-hooks delegate or its entry in your hook manager. Preserve unrelated hook commands.
326
+
327
+ ## Troubleshooting
328
+
329
+ | Symptom | Check |
330
+ | --- | --- |
331
+ | Hook does not run | Reinstall with your chosen hook manager and inspect `git config --get core.hooksPath`. |
332
+ | Missing runner or authentication failure | Ensure the CLI is on the hook's `PATH`, authenticated, and configured with an available model. |
333
+ | Unknown profile | Match `runner` to an existing `[runners.<name>]`. |
334
+ | Runner capability error | Update the CLI to a version with the required adapter flags. |
335
+ | Invalid JSON | Check the prompt and schema; invalid JSON is retried twice by default. |
336
+ | Push blocked after edits | Review `git diff`, run checks, commit approved changes, and retry the push. |
337
+ | Need diagnostics | Inspect `.git/ai-push-hooks/logs` and `.git/ai-push-hooks/summaries`. |
338
+
339
+ For validation commands, see [Contributing](../CONTRIBUTING.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-push-hooks",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Run structured AI-assisted checks and allowlisted maintenance before git push",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -30,7 +30,7 @@
30
30
  "ai-push-hooks": "bin/ai-push-hooks.js"
31
31
  },
32
32
  "scripts": {
33
- "test": "uv run --no-project --with pytest --with build --with pip --with \"tomli; python_version < '3.11'\" pytest tests -q",
33
+ "test": "uv run --no-project --with \".[dev]\" python -m pytest tests -q",
34
34
  "test:npm-pack": "node tests/npm-pack-smoke.mjs"
35
35
  },
36
36
  "files": [
@@ -38,9 +38,9 @@
38
38
  "src/**/*.py",
39
39
  "vendor",
40
40
  "README.md",
41
+ "docs/configuration.md",
41
42
  "CHANGELOG.md",
42
43
  "SECURITY.md",
43
- "run.sh",
44
44
  "pyproject.toml",
45
45
  "LICENSE",
46
46
  "ai-push-hooks.toml"
package/pyproject.toml CHANGED
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools>=69", "wheel"]
2
+ requires = ["setuptools>=77", "wheel"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-push-hooks"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "Run structured AI-assisted checks and allowlisted maintenance before git push"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -34,7 +34,7 @@ Changelog = "https://github.com/shanebishop1/ai-push-hooks/blob/main/CHANGELOG.m
34
34
  ai-push-hooks = "ai_push_hooks.cli:main"
35
35
 
36
36
  [project.optional-dependencies]
37
- dev = ["pytest>=8.0"]
37
+ dev = ["pytest>=8.0", "build", "pip", "ruff", "twine"]
38
38
 
39
39
  [tool.setuptools]
40
40
  include-package-data = true
@@ -44,3 +44,12 @@ where = ["src"]
44
44
 
45
45
  [tool.pytest.ini_options]
46
46
  testpaths = ["tests"]
47
+
48
+ [tool.ruff]
49
+ target-version = "py310"
50
+ line-length = 88
51
+
52
+ [tool.ruff.format]
53
+ quote-style = "double"
54
+ indent-style = "space"
55
+ line-ending = "lf"
@@ -45,11 +45,17 @@ class ArtifactStore:
45
45
  step_name = validate_path_component(step_id, "Artifact step id")
46
46
  lexical_module_path = self.run_dir / module_name
47
47
  if path_has_symlink(self.run_dir, lexical_module_path):
48
- raise HookError(f"Artifact module path must not traverse a symlink: {module_id}")
49
- module_path = resolve_contained_path(self.run_dir, module_name, "Artifact module path")
48
+ raise HookError(
49
+ f"Artifact module path must not traverse a symlink: {module_id}"
50
+ )
51
+ module_path = resolve_contained_path(
52
+ self.run_dir, module_name, "Artifact module path"
53
+ )
50
54
  lexical_step_path = module_path / f"{step_index:02d}-{step_name}"
51
55
  if path_has_symlink(self.run_dir, lexical_step_path):
52
- raise HookError(f"Artifact step path must not traverse a symlink: {step_id}")
56
+ raise HookError(
57
+ f"Artifact step path must not traverse a symlink: {step_id}"
58
+ )
53
59
  path = resolve_contained_path(
54
60
  module_path,
55
61
  f"{step_index:02d}-{step_name}",
@@ -125,7 +131,9 @@ class ArtifactStore:
125
131
  payload: Any,
126
132
  ) -> pathlib.Path:
127
133
  path = self._artifact_path(state.module.id, step_index, step_id, artifact_name)
128
- write_text_no_follow(path, json.dumps(payload, ensure_ascii=True, indent=2) + "\n")
134
+ write_text_no_follow(
135
+ path, json.dumps(payload, ensure_ascii=True, indent=2) + "\n"
136
+ )
129
137
  return self.register(state, step_id, artifact_name, path)
130
138
 
131
139
  def serialize_plugin_artifacts(
@@ -152,7 +160,10 @@ class ArtifactStore:
152
160
  try:
153
161
  if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
154
162
  content = (
155
- json.dumps(payload, ensure_ascii=True, indent=2, allow_nan=False) + "\n"
163
+ json.dumps(
164
+ payload, ensure_ascii=True, indent=2, allow_nan=False
165
+ )
166
+ + "\n"
156
167
  ).encode("utf-8")
157
168
  elif isinstance(payload, str):
158
169
  content = payload.encode("utf-8")
@@ -169,7 +180,9 @@ class ArtifactStore:
169
180
  )
170
181
  total_bytes += len(content)
171
182
  if total_bytes > max_total_bytes:
172
- raise HookError("CollectorResult artifacts exceed the aggregate size limit")
183
+ raise HookError(
184
+ "CollectorResult artifacts exceed the aggregate size limit"
185
+ )
173
186
  serialized[artifact_name] = content
174
187
  return serialized
175
188
 
@@ -43,30 +43,44 @@ def init_config(template: str, force: bool, cwd: pathlib.Path | None = None) ->
43
43
  except FileNotFoundError:
44
44
  metadata = None
45
45
  except OSError as exc:
46
- raise HookError(f"Could not inspect config path {config_path}: {exc}") from exc
46
+ raise HookError(
47
+ f"Could not inspect config path {config_path}: {exc}"
48
+ ) from exc
47
49
  if metadata is not None:
48
50
  if path_is_link_or_reparse(config_path):
49
- raise HookError(f"Refusing to replace symlink or reparse point: {config_path}")
51
+ raise HookError(
52
+ f"Refusing to replace symlink or reparse point: {config_path}"
53
+ )
50
54
  if not stat.S_ISREG(metadata.st_mode):
51
- raise HookError(f"Refusing to replace non-regular config path: {config_path}")
55
+ raise HookError(
56
+ f"Refusing to replace non-regular config path: {config_path}"
57
+ )
52
58
  try:
53
59
  write_text_no_follow(config_path, MINIMAL_DOCS_TEMPLATE)
54
60
  except HookError:
55
61
  raise
56
62
  except OSError as exc:
57
- raise HookError(f"Could not write config file {config_path}: {exc}") from exc
63
+ raise HookError(
64
+ f"Could not write config file {config_path}: {exc}"
65
+ ) from exc
58
66
  else:
59
67
  try:
60
68
  metadata = config_path.lstat()
61
69
  except FileNotFoundError:
62
70
  metadata = None
63
71
  except OSError as exc:
64
- raise HookError(f"Could not inspect config path {config_path}: {exc}") from exc
72
+ raise HookError(
73
+ f"Could not inspect config path {config_path}: {exc}"
74
+ ) from exc
65
75
  if metadata is not None:
66
76
  if path_is_link_or_reparse(config_path):
67
- raise HookError(f"Refusing to overwrite symlink or reparse point: {config_path}")
77
+ raise HookError(
78
+ f"Refusing to overwrite symlink or reparse point: {config_path}"
79
+ )
68
80
  if not stat.S_ISREG(metadata.st_mode):
69
- raise HookError(f"Refusing to overwrite non-regular config path: {config_path}")
81
+ raise HookError(
82
+ f"Refusing to overwrite non-regular config path: {config_path}"
83
+ )
70
84
  flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
71
85
  flags |= getattr(os, "O_NOFOLLOW", 0)
72
86
  try:
@@ -76,7 +90,9 @@ def init_config(template: str, force: bool, cwd: pathlib.Path | None = None) ->
76
90
  f"Refusing to overwrite existing config without --force: {config_path}"
77
91
  ) from exc
78
92
  except OSError as exc:
79
- raise HookError(f"Could not create config file {config_path}: {exc}") from exc
93
+ raise HookError(
94
+ f"Could not create config file {config_path}: {exc}"
95
+ ) from exc
80
96
  try:
81
97
  with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
82
98
  descriptor = -1