docwrap 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {docwrap-0.1.0 → docwrap-0.2.0}/.gitignore +1 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/PKG-INFO +20 -20
- {docwrap-0.1.0 → docwrap-0.2.0}/README.md +11 -11
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/tests/test_agy_adapter.py +1 -1
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/.claude-plugin/plugin.json +1 -1
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/_docwrap/_adapter.py +30 -5
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/tests/test_claude_adapter.py +91 -32
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/.codex-plugin/plugin.json +1 -1
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/_docwrap/_adapter.py +30 -5
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/tests/test_codex_adapter.py +36 -1
- {docwrap-0.1.0 → docwrap-0.2.0}/pyproject.toml +23 -20
- docwrap-0.2.0/src/docwrap/__init__.py +91 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/__main__.py +1 -1
- docwrap-0.2.0/src/docwrap/_arguments.py +20 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_cli.py +221 -249
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_color.py +17 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_config.py +35 -76
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_convert.py +0 -2
- docwrap-0.2.0/src/docwrap/_core/__init__.py +26 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_discover.py +113 -94
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_incremental.py +16 -12
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_normalize.py +1 -1
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_parse.py +1 -1
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_process.py +2 -2
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_wrap.py +34 -59
- docwrap-0.2.0/src/docwrap/_display/__init__.py +9 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_diff.py +4 -2
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_json.py +9 -26
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_print.py +2 -2
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_recommend.py +9 -22
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_skips.py +2 -2
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_stats.py +11 -10
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_summary.py +45 -32
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_downstream.py +28 -51
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_files.py +89 -77
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_git.py +103 -133
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_git_utils.py +57 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_hook.py +139 -317
- docwrap-0.2.0/src/docwrap/_hook_front.py +405 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_hosts/__init__.py +2 -2
- docwrap-0.1.0/src/docwrap/__init__.py → docwrap-0.2.0/src/docwrap/_library.py +1 -7
- docwrap-0.2.0/src/docwrap/_main.py +45 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_stats.py +45 -40
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_styles/__init__.py +12 -9
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_styles/_google.py +18 -6
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_styles/_numpy.py +15 -3
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_styles/_sphinx.py +17 -15
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_types.py +39 -53
- docwrap-0.2.0/src/docwrap/_vendor/__init__.py +1 -0
- docwrap-0.2.0/src/docwrap/_vendor/hookshell/__init__.py +7 -0
- {docwrap-0.1.0/src/docwrap → docwrap-0.2.0/src/docwrap/_vendor/hookshell}/_classify.py +30 -12
- docwrap-0.2.0/src/docwrap/_vendor/hookshell/_detector.py +201 -0
- {docwrap-0.1.0/src/docwrap → docwrap-0.2.0/src/docwrap/_vendor/hookshell}/_grammar.py +50 -36
- {docwrap-0.1.0/src/docwrap → docwrap-0.2.0/src/docwrap/_vendor/hookshell}/_shellparse.py +93 -44
- docwrap-0.2.0/src/docwrap/_vendor/hookshell/manifest.json +18 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/hooks/__init__.py +1 -1
- docwrap-0.1.0/src/docwrap/_core/__init__.py +0 -27
- docwrap-0.1.0/src/docwrap/_detector.py +0 -166
- docwrap-0.1.0/src/docwrap/_display/__init__.py +0 -17
- {docwrap-0.1.0 → docwrap-0.2.0}/LICENSE +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/_build.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/_docwrap/__init__.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/_docwrap/_adapter.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/adapter.toml +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/hooks.json +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/plugin.json +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/_docwrap/__init__.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/adapter.toml +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/hooks/hooks.json +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/_docwrap/__init__.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/adapter.toml +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/hooks/hooks.json +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_canonical.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_codeblock.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_escape.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_detect.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_text.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_exceptions.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_validate.py +0 -0
- {docwrap-0.1.0/src/docwrap → docwrap-0.2.0/src/docwrap/_vendor/hookshell}/_shell.py +0 -0
- {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: docwrap
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Wrap Python docstrings with support for Google, NumPy, and Sphinx styles
|
|
5
5
|
Project-URL: Documentation, https://github.com/dactylo/docwrap/tree/main/docs
|
|
6
6
|
Project-URL: Homepage, https://github.com/dactylo/docwrap
|
|
@@ -19,24 +19,22 @@ Description-Content-Type: text/markdown
|
|
|
19
19
|
|
|
20
20
|
# docwrap
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
Docwrap applies configurable formatting rules to Python docstrings. It makes wrapping and layout
|
|
22
|
+
docwrap applies configurable formatting rules to Python docstrings. It makes wrapping and layout
|
|
25
23
|
deterministic, with controls for line length, paragraph reflow, and summary placement. It supports
|
|
26
24
|
Google, NumPy, and Sphinx/reST conventions.
|
|
27
25
|
|
|
28
26
|
Run `docwrap` to see which docstrings would change across your project:
|
|
29
27
|
|
|
30
|
-

|
|
28
|
+

|
|
31
29
|
|
|
32
30
|
Then use `docwrap --preview` to compare the original and proposed formatting. For example, using
|
|
33
31
|
the default 79-column limit:
|
|
34
32
|
|
|
35
|
-

|
|
33
|
+

|
|
36
34
|
|
|
37
35
|
## Installation
|
|
38
36
|
|
|
39
|
-
|
|
37
|
+
docwrap requires CPython 3.13 or later. From a checkout, install the command with
|
|
40
38
|
[uv](https://docs.astral.sh/uv/):
|
|
41
39
|
|
|
42
40
|
```bash
|
|
@@ -78,17 +76,17 @@ line-length = 88
|
|
|
78
76
|
```
|
|
79
77
|
|
|
80
78
|
Configuration is discovered from each file's `pyproject.toml`. The
|
|
81
|
-
[wrapping policy guide](docs/design.md#choosing-a-wrapping-policy) explains the tradeoffs between
|
|
82
|
-
reflowing prose and preserving authored layout. See the [CLI reference](docs/cli.md) for output
|
|
79
|
+
[wrapping policy guide](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/design.md#choosing-a-wrapping-policy) explains the tradeoffs between
|
|
80
|
+
reflowing prose and preserving authored layout. See the [CLI reference](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/cli.md) for output
|
|
83
81
|
modes, configuration precedence, and advanced options.
|
|
84
82
|
|
|
85
83
|
## Integrations and library use
|
|
86
84
|
|
|
87
85
|
A local [pre-commit](https://pre-commit.com/) hook can run docwrap when you commit. See the
|
|
88
|
-
[pre-commit setup](docs/hook.md#pre-commit) for a writing hook and its report-only variant. Agent
|
|
86
|
+
[pre-commit setup](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/hook.md#pre-commit) for a writing hook and its report-only variant. Agent
|
|
89
87
|
hooks for Claude Code, Codex, and Antigravity can run before commits or after edits. Their
|
|
90
88
|
installation, host settings, commit behavior, and composition limits are in the
|
|
91
|
-
[agent hook guide](docs/hook.md).
|
|
89
|
+
[agent hook guide](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/hook.md).
|
|
92
90
|
|
|
93
91
|
For Python callers, `wrap_source()` returns rewritten source, `check()` reports whether it would
|
|
94
92
|
change, and `wrap_source_report()` supplies structured results:
|
|
@@ -101,21 +99,23 @@ result = docwrap.check(source)
|
|
|
101
99
|
wrapped = docwrap.wrap_source(source)
|
|
102
100
|
```
|
|
103
101
|
|
|
104
|
-
See the [library reference](docs/library.md) for file operations, configuration, result types, and
|
|
102
|
+
See the [library reference](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/library.md) for file operations, configuration, result types, and
|
|
105
103
|
safety skips.
|
|
106
104
|
|
|
107
105
|
## Documentation
|
|
108
106
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
| If you want to... | Read |
|
|
108
|
+
| ------------------------------------------------------ | ------------------------------------------ |
|
|
109
|
+
| Look up commands, output, configuration, or exit codes | [CLI reference](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/cli.md) |
|
|
110
|
+
| Set up agent hooks, commit handling, or host responses | [Agent hook guide](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/hook.md) |
|
|
111
|
+
| Wrap only changed files or definitions | [Incremental mode](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/incremental.md) |
|
|
112
|
+
| Call docwrap from Python | [Library reference](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/library.md) |
|
|
113
|
+
| Choose a style and understand what docwrap preserves | [Concepts and safety](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/design.md) |
|
|
114
|
+
| Check supported environments and file behavior | [Platform compatibility](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/platform.md) |
|
|
115
115
|
|
|
116
116
|
## Development
|
|
117
117
|
|
|
118
|
-
See the [development guide](docs/development.md) for pinned CI dependencies and lock updates.
|
|
118
|
+
See the [development guide](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/development.md) for pinned CI dependencies and lock updates.
|
|
119
119
|
|
|
120
120
|
From the checkout, run `make check` for repository checks. Run `make format` to apply the
|
|
121
121
|
repository's formatters. The Makefile also provides `make install`, `make reinstall`, and
|
|
@@ -123,4 +123,4 @@ repository's formatters. The Makefile also provides `make install`, `make reinst
|
|
|
123
123
|
|
|
124
124
|
## License
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
docwrap is available under the [MIT License](https://github.com/dactylo/docwrap/blob/v0.2.0/LICENSE).
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# docwrap
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Docwrap applies configurable formatting rules to Python docstrings. It makes wrapping and layout
|
|
3
|
+
docwrap applies configurable formatting rules to Python docstrings. It makes wrapping and layout
|
|
6
4
|
deterministic, with controls for line length, paragraph reflow, and summary placement. It supports
|
|
7
5
|
Google, NumPy, and Sphinx/reST conventions.
|
|
8
6
|
|
|
@@ -17,7 +15,7 @@ the default 79-column limit:
|
|
|
17
15
|
|
|
18
16
|
## Installation
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
docwrap requires CPython 3.13 or later. From a checkout, install the command with
|
|
21
19
|
[uv](https://docs.astral.sh/uv/):
|
|
22
20
|
|
|
23
21
|
```bash
|
|
@@ -87,12 +85,14 @@ safety skips.
|
|
|
87
85
|
|
|
88
86
|
## Documentation
|
|
89
87
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
| If you want to... | Read |
|
|
89
|
+
| ------------------------------------------------------ | ------------------------------------------ |
|
|
90
|
+
| Look up commands, output, configuration, or exit codes | [CLI reference](docs/cli.md) |
|
|
91
|
+
| Set up agent hooks, commit handling, or host responses | [Agent hook guide](docs/hook.md) |
|
|
92
|
+
| Wrap only changed files or definitions | [Incremental mode](docs/incremental.md) |
|
|
93
|
+
| Call docwrap from Python | [Library reference](docs/library.md) |
|
|
94
|
+
| Choose a style and understand what docwrap preserves | [Concepts and safety](docs/design.md) |
|
|
95
|
+
| Check supported environments and file behavior | [Platform compatibility](docs/platform.md) |
|
|
96
96
|
|
|
97
97
|
## Development
|
|
98
98
|
|
|
@@ -104,4 +104,4 @@ repository's formatters. The Makefile also provides `make install`, `make reinst
|
|
|
104
104
|
|
|
105
105
|
## License
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
docwrap is available under the [MIT License](LICENSE).
|
|
@@ -8,7 +8,7 @@ from typing import Literal
|
|
|
8
8
|
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
|
-
from docwrap.
|
|
11
|
+
from docwrap._hook_front import _build_hook_parser
|
|
12
12
|
from docwrap._hosts import load_adapter
|
|
13
13
|
from docwrap.hooks import API_VERSION, HookAdapter, HookResult, HostInput, HostResponse
|
|
14
14
|
from plugins.agy._docwrap._adapter import AntigravityAdapter
|
|
@@ -36,11 +36,16 @@ def _permission_deny(reason: str, stderr_prefix: str = '') -> HostResponse:
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
class ClaudeAdapter:
|
|
39
|
-
"""Translate Claude Code `PreToolUse` and `PostToolUse` payloads.
|
|
39
|
+
"""Translate Claude Code `PreToolUse` and `PostToolUse` payloads.
|
|
40
|
+
|
|
41
|
+
Call `parse()` before `render()` on the same instance. Parsing each payload
|
|
42
|
+
replaces the event context used to choose the response format.
|
|
43
|
+
"""
|
|
40
44
|
|
|
41
45
|
name = 'claude'
|
|
42
46
|
api_version = API_VERSION
|
|
43
47
|
requires_event = False
|
|
48
|
+
_post_tool = False
|
|
44
49
|
|
|
45
50
|
def parse(self, payload: Mapping[str, object], event: str | None) -> HostInput | None:
|
|
46
51
|
"""Read `hook_event_name`, `tool_name`, `tool_input`, and `cwd`.
|
|
@@ -58,6 +63,7 @@ class ClaudeAdapter:
|
|
|
58
63
|
`None`.
|
|
59
64
|
"""
|
|
60
65
|
del event
|
|
66
|
+
self._post_tool = False
|
|
61
67
|
hook_event = payload.get('hook_event_name')
|
|
62
68
|
tool_name = payload.get('tool_name')
|
|
63
69
|
tool_input = object_field(payload, 'tool_input')
|
|
@@ -72,6 +78,7 @@ class ClaudeAdapter:
|
|
|
72
78
|
command_cwd,
|
|
73
79
|
)
|
|
74
80
|
if hook_event == 'PostToolUse':
|
|
81
|
+
self._post_tool = True
|
|
75
82
|
editing = isinstance(tool_name, str) and tool_name in _EDIT_TOOLS
|
|
76
83
|
path = tool_input.get('file_path') if editing else None
|
|
77
84
|
paths = (path,) if isinstance(path, str) and path else ()
|
|
@@ -79,16 +86,20 @@ class ClaudeAdapter:
|
|
|
79
86
|
return None
|
|
80
87
|
|
|
81
88
|
def render(self, result: HookResult) -> HostResponse:
|
|
82
|
-
"""Render
|
|
89
|
+
"""Render feedback using the event recorded by `parse()`.
|
|
83
90
|
|
|
84
91
|
Args:
|
|
85
92
|
result: Decision, reason, and notes from docwrap.
|
|
86
93
|
|
|
87
94
|
Returns:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
For pre-tool input, empty stdout for a silent allow, a system
|
|
96
|
+
message for allow feedback, or deny JSON with the denial reason on
|
|
97
|
+
stderr. For post-tool input, distinct nonempty diagnostic lines as
|
|
98
|
+
additional context, or empty stdout when there are none.
|
|
99
|
+
Notes remain on stderr and the exit status is 0.
|
|
91
100
|
"""
|
|
101
|
+
if self._post_tool:
|
|
102
|
+
return _post_tool_response(result)
|
|
92
103
|
notes = notes_text(result)
|
|
93
104
|
if result.decision == 'deny':
|
|
94
105
|
return _permission_deny(result.reason or 'docwrap denied the operation', notes)
|
|
@@ -104,3 +115,17 @@ class ClaudeAdapter:
|
|
|
104
115
|
The deny JSON on stdout, the reason on stderr, and exit 0.
|
|
105
116
|
"""
|
|
106
117
|
return _permission_deny(reason)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _post_tool_response(result: HookResult) -> HostResponse:
|
|
121
|
+
"""Render distinct diagnostic lines as post-tool context."""
|
|
122
|
+
lines = '\n'.join((*result.notes, result.reason or '')).split('\n')
|
|
123
|
+
context = '\n'.join(dict.fromkeys(line for line in lines if line))
|
|
124
|
+
payload = {
|
|
125
|
+
'hookSpecificOutput': {
|
|
126
|
+
'hookEventName': 'PostToolUse',
|
|
127
|
+
'additionalContext': context,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
stdout = f'{json.dumps(payload)}\n' if context else ''
|
|
131
|
+
return HostResponse(stdout, notes_text(result), 0)
|
|
@@ -8,8 +8,7 @@ from typing import Literal
|
|
|
8
8
|
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
|
-
from docwrap.
|
|
12
|
-
from docwrap._hook import run_hook
|
|
11
|
+
from docwrap._hook_front import _build_hook_parser, run_hook
|
|
13
12
|
from docwrap._hosts import load_adapter
|
|
14
13
|
from docwrap.hooks import (
|
|
15
14
|
API_VERSION,
|
|
@@ -20,9 +19,9 @@ from docwrap.hooks import (
|
|
|
20
19
|
HostResponse,
|
|
21
20
|
)
|
|
22
21
|
from plugins.claude._docwrap._adapter import ClaudeAdapter
|
|
22
|
+
from tests.helpers import decode_text, git_commit_all, run_setup
|
|
23
23
|
|
|
24
24
|
ROOT = Path(__file__).resolve().parents[3]
|
|
25
|
-
CORPUS = ROOT / 'tests' / 'data' / 'commit_classification.json'
|
|
26
25
|
DENY_JSON = {
|
|
27
26
|
'hookSpecificOutput': {
|
|
28
27
|
'hookEventName': 'PreToolUse',
|
|
@@ -43,6 +42,8 @@ def _decision(stdout: str) -> str | None:
|
|
|
43
42
|
if not stdout:
|
|
44
43
|
return None
|
|
45
44
|
data = json.loads(stdout)
|
|
45
|
+
if 'systemMessage' in data:
|
|
46
|
+
return None
|
|
46
47
|
if 'hookSpecificOutput' in data:
|
|
47
48
|
return str(data['hookSpecificOutput']['permissionDecision'])
|
|
48
49
|
return str(data['example']['decision'])
|
|
@@ -65,39 +66,63 @@ class TestClaudeAdapter:
|
|
|
65
66
|
False,
|
|
66
67
|
)
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
@pytest.mark.usefixtures('example_hosts')
|
|
70
|
+
@pytest.mark.parametrize('host', ['claude', 'example'])
|
|
71
|
+
@pytest.mark.parametrize(
|
|
72
|
+
('command', 'outcome'),
|
|
73
|
+
[
|
|
74
|
+
pytest.param('ls', 'unrelated', id='unrelated'),
|
|
75
|
+
pytest.param('git commit --future-option -m x', 'denied', id='denied'),
|
|
76
|
+
pytest.param('git commit -m x', 'wrapped', id='supported'),
|
|
77
|
+
],
|
|
78
|
+
)
|
|
79
|
+
def test_hook_decisions_preserve_adapter_independence(
|
|
69
80
|
self,
|
|
70
81
|
tmp_path: Path,
|
|
71
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
72
82
|
capsys: pytest.CaptureFixture[str],
|
|
73
|
-
|
|
83
|
+
host: str,
|
|
84
|
+
command: str,
|
|
85
|
+
outcome: Literal['unrelated', 'denied', 'wrapped'],
|
|
74
86
|
) -> None:
|
|
75
|
-
|
|
76
|
-
(
|
|
77
|
-
(tmp_path / '
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
87
|
+
run_setup(['git', 'init'], cwd=tmp_path)
|
|
88
|
+
run_setup(['git', 'config', 'docwrap.hook', 'true'], cwd=tmp_path)
|
|
89
|
+
(tmp_path / 'pyproject.toml').write_text(
|
|
90
|
+
'[tool.docwrap]\nline-length = 40\nfill-style = "greedy"\n', encoding='utf-8'
|
|
91
|
+
)
|
|
92
|
+
target = tmp_path / 'sample.py'
|
|
93
|
+
target.write_text('pass\n', encoding='utf-8')
|
|
94
|
+
git_commit_all(tmp_path)
|
|
95
|
+
source = (
|
|
96
|
+
'def f():\n """Summary.\n\n'
|
|
97
|
+
' one two three four five six seven eight nine ten eleven twelve\n'
|
|
98
|
+
' """\n'
|
|
99
|
+
)
|
|
100
|
+
target.write_text(source, encoding='utf-8')
|
|
101
|
+
run_setup(['git', 'add', 'sample.py'], cwd=tmp_path)
|
|
102
|
+
payload = json.dumps(
|
|
103
|
+
_payload('PreToolUse', 'Bash', {'command': command}) | {'cwd': str(tmp_path)}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
status = run_hook(payload, host=host)
|
|
107
|
+
|
|
108
|
+
captured = capsys.readouterr()
|
|
109
|
+
expected_decision = 'deny' if outcome == 'denied' else None
|
|
110
|
+
assert (status, _decision(captured.out)) == (0, expected_decision)
|
|
111
|
+
if expected_decision == 'deny':
|
|
112
|
+
assert 'unsupported commit command' in captured.err
|
|
113
|
+
else:
|
|
114
|
+
assert captured.err == ''
|
|
115
|
+
expected = (
|
|
116
|
+
'def f():\n """Summary.\n\n'
|
|
117
|
+
' one two three four five six seven\n'
|
|
118
|
+
' eight nine ten eleven twelve\n'
|
|
119
|
+
' """\n'
|
|
120
|
+
if outcome == 'wrapped'
|
|
121
|
+
else source
|
|
122
|
+
)
|
|
123
|
+
staged = run_setup(['git', 'show', ':sample.py'], cwd=tmp_path)
|
|
124
|
+
assert decode_text(staged.stdout) == expected
|
|
125
|
+
assert target.read_text(encoding='utf-8') == expected
|
|
101
126
|
|
|
102
127
|
def test_parse_missing_cwd_uses_process_directory(
|
|
103
128
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
@@ -169,6 +194,40 @@ class TestClaudeAdapter:
|
|
|
169
194
|
assert response.stderr == 'docwrap: reason\n'
|
|
170
195
|
assert response.exit_code == 0
|
|
171
196
|
|
|
197
|
+
@pytest.mark.parametrize(
|
|
198
|
+
('reason', 'notes', 'context'),
|
|
199
|
+
[
|
|
200
|
+
(None, ('Cannot preserve layout.',), 'Cannot preserve layout.'),
|
|
201
|
+
('Cannot wrap a.py: invalid syntax.', (), 'Cannot wrap a.py: invalid syntax.'),
|
|
202
|
+
('first\nsecond', ('first', 'diagnostic'), 'first\ndiagnostic\nsecond'),
|
|
203
|
+
],
|
|
204
|
+
)
|
|
205
|
+
def test_render_post_tool_context(
|
|
206
|
+
self, reason: str | None, notes: tuple[str, ...], context: str
|
|
207
|
+
) -> None:
|
|
208
|
+
adapter = ClaudeAdapter()
|
|
209
|
+
assert adapter.parse(_payload('PostToolUse', 'Edit', {'file_path': 'a.py'}), None)
|
|
210
|
+
response = adapter.render(_result('allow', reason, notes))
|
|
211
|
+
assert json.loads(response.stdout) == {
|
|
212
|
+
'hookSpecificOutput': {
|
|
213
|
+
'hookEventName': 'PostToolUse',
|
|
214
|
+
'additionalContext': context,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
assert response.stderr == ''.join(f'{note}\n' for note in notes)
|
|
218
|
+
assert response.exit_code == 0
|
|
219
|
+
|
|
220
|
+
@pytest.mark.parametrize('event', ['PreToolUse', 'Notification', None])
|
|
221
|
+
def test_render_resets_post_tool_context(self, event: str | None) -> None:
|
|
222
|
+
adapter = ClaudeAdapter()
|
|
223
|
+
adapter.parse(_payload('PostToolUse', 'Edit', {'file_path': 'a.py'}), None)
|
|
224
|
+
assert adapter.render(_result('allow')) == HostResponse('', '', 0)
|
|
225
|
+
adapter.parse({'hook_event_name': event}, None)
|
|
226
|
+
response = adapter.render(_result('deny', 'docwrap: reason'))
|
|
227
|
+
assert json.loads(response.stdout) == DENY_JSON
|
|
228
|
+
receipt = adapter.render(_result('allow', 'staged rewrite receipt'))
|
|
229
|
+
assert json.loads(receipt.stdout) == {'systemMessage': 'staged rewrite receipt'}
|
|
230
|
+
|
|
172
231
|
|
|
173
232
|
@pytest.mark.usefixtures('registered_hosts')
|
|
174
233
|
def test_native_manifest_command_loads_this_adapter() -> None:
|
|
@@ -54,11 +54,16 @@ def _permission_deny(reason: str, stderr_prefix: str = '') -> HostResponse:
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
class CodexAdapter:
|
|
57
|
-
"""Translate Codex
|
|
57
|
+
"""Translate Codex PreToolUse and PostToolUse payloads.
|
|
58
|
+
|
|
59
|
+
Call `parse()` before `render()` on the same instance. Parsing each payload
|
|
60
|
+
replaces the event context used to choose the response format.
|
|
61
|
+
"""
|
|
58
62
|
|
|
59
63
|
name = 'codex'
|
|
60
64
|
api_version = API_VERSION
|
|
61
65
|
requires_event = False
|
|
66
|
+
_post_tool = False
|
|
62
67
|
|
|
63
68
|
def parse(self, payload: Mapping[str, object], event: str | None) -> HostInput | None:
|
|
64
69
|
"""Read `hook_event_name`, `tool_name`, `tool_input`, and `cwd`.
|
|
@@ -76,6 +81,7 @@ class CodexAdapter:
|
|
|
76
81
|
`None`. Post-tool paths come from the `apply_patch` command text.
|
|
77
82
|
"""
|
|
78
83
|
del event
|
|
84
|
+
self._post_tool = False
|
|
79
85
|
hook_event = payload.get('hook_event_name')
|
|
80
86
|
tool_name = payload.get('tool_name')
|
|
81
87
|
tool_input = object_field(payload, 'tool_input')
|
|
@@ -84,22 +90,27 @@ class CodexAdapter:
|
|
|
84
90
|
command = tool_input.get('command') if tool_name == 'Bash' else None
|
|
85
91
|
return HostInput('pre_tool', command if isinstance(command, str) else None, cwd, None)
|
|
86
92
|
if hook_event == 'PostToolUse':
|
|
93
|
+
self._post_tool = True
|
|
87
94
|
command = tool_input.get('command') if tool_name == _EDIT_TOOL else None
|
|
88
95
|
paths = _apply_patch_paths(command) if isinstance(command, str) else ()
|
|
89
96
|
return HostInput('post_tool', None, cwd, None, paths)
|
|
90
97
|
return None
|
|
91
98
|
|
|
92
99
|
def render(self, result: HookResult) -> HostResponse:
|
|
93
|
-
"""Render
|
|
100
|
+
"""Render feedback using the event recorded by `parse()`.
|
|
94
101
|
|
|
95
102
|
Args:
|
|
96
103
|
result: Decision, reason, and notes from docwrap.
|
|
97
104
|
|
|
98
105
|
Returns:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
For pre-tool input, empty stdout for a silent allow, a system
|
|
107
|
+
message for allow feedback, or deny JSON with the denial reason on
|
|
108
|
+
stderr. For post-tool input, distinct nonempty diagnostic lines as
|
|
109
|
+
additional context, or empty stdout when there are none.
|
|
110
|
+
Notes remain on stderr and the exit status is 0.
|
|
102
111
|
"""
|
|
112
|
+
if self._post_tool:
|
|
113
|
+
return _post_tool_response(result)
|
|
103
114
|
notes = notes_text(result)
|
|
104
115
|
if result.decision == 'deny':
|
|
105
116
|
return _permission_deny(result.reason or 'docwrap denied the operation', notes)
|
|
@@ -115,3 +126,17 @@ class CodexAdapter:
|
|
|
115
126
|
The deny JSON on stdout, the reason on stderr, and exit 0.
|
|
116
127
|
"""
|
|
117
128
|
return _permission_deny(reason)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _post_tool_response(result: HookResult) -> HostResponse:
|
|
132
|
+
"""Render distinct diagnostic lines as post-tool context."""
|
|
133
|
+
lines = '\n'.join((*result.notes, result.reason or '')).split('\n')
|
|
134
|
+
context = '\n'.join(dict.fromkeys(line for line in lines if line))
|
|
135
|
+
payload = {
|
|
136
|
+
'hookSpecificOutput': {
|
|
137
|
+
'hookEventName': 'PostToolUse',
|
|
138
|
+
'additionalContext': context,
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
stdout = f'{json.dumps(payload)}\n' if context else ''
|
|
142
|
+
return HostResponse(stdout, notes_text(result), 0)
|
|
@@ -8,7 +8,7 @@ from typing import Literal
|
|
|
8
8
|
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
|
-
from docwrap.
|
|
11
|
+
from docwrap._hook_front import _build_hook_parser
|
|
12
12
|
from docwrap._hosts import load_adapter
|
|
13
13
|
from docwrap.hooks import API_VERSION, HookAdapter, HookResult, HostInput, HostResponse
|
|
14
14
|
from plugins.codex._docwrap._adapter import CodexAdapter
|
|
@@ -71,6 +71,41 @@ class TestCodexAdapter:
|
|
|
71
71
|
assert json.loads(response.stdout) == DENY_JSON
|
|
72
72
|
assert (response.stderr, response.exit_code) == ('docwrap: reason\n', 0)
|
|
73
73
|
|
|
74
|
+
@pytest.mark.parametrize(
|
|
75
|
+
('reason', 'notes', 'context'),
|
|
76
|
+
[
|
|
77
|
+
(None, ('Cannot preserve layout.',), 'Cannot preserve layout.'),
|
|
78
|
+
('Cannot wrap a.py: invalid syntax.', (), 'Cannot wrap a.py: invalid syntax.'),
|
|
79
|
+
('first\nsecond', ('first', 'diagnostic'), 'first\ndiagnostic\nsecond'),
|
|
80
|
+
],
|
|
81
|
+
)
|
|
82
|
+
def test_render_post_tool_context(
|
|
83
|
+
self, reason: str | None, notes: tuple[str, ...], context: str
|
|
84
|
+
) -> None:
|
|
85
|
+
adapter = CodexAdapter()
|
|
86
|
+
payload = _payload('PostToolUse', 'apply_patch', {'command': '*** Update File: a.py\n'})
|
|
87
|
+
assert adapter.parse(payload, None)
|
|
88
|
+
response = adapter.render(_result('allow', reason, notes))
|
|
89
|
+
assert json.loads(response.stdout) == {
|
|
90
|
+
'hookSpecificOutput': {
|
|
91
|
+
'hookEventName': 'PostToolUse',
|
|
92
|
+
'additionalContext': context,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
assert response.stderr == ''.join(f'{note}\n' for note in notes)
|
|
96
|
+
assert response.exit_code == 0
|
|
97
|
+
|
|
98
|
+
@pytest.mark.parametrize('event', ['PreToolUse', 'Notification', None])
|
|
99
|
+
def test_render_resets_post_tool_context(self, event: str | None) -> None:
|
|
100
|
+
adapter = CodexAdapter()
|
|
101
|
+
adapter.parse(_payload('PostToolUse', 'apply_patch', {}), None)
|
|
102
|
+
assert adapter.render(_result('allow')) == HostResponse('', '', 0)
|
|
103
|
+
adapter.parse({'hook_event_name': event}, None)
|
|
104
|
+
response = adapter.render(_result('deny', 'docwrap: reason'))
|
|
105
|
+
assert json.loads(response.stdout) == DENY_JSON
|
|
106
|
+
receipt = adapter.render(_result('allow', 'staged rewrite receipt'))
|
|
107
|
+
assert json.loads(receipt.stdout) == {'systemMessage': 'staged rewrite receipt'}
|
|
108
|
+
|
|
74
109
|
|
|
75
110
|
@dataclass(frozen=True)
|
|
76
111
|
class _Result:
|