hook-shipyard 0.1.0__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.
- hook_shipyard/__init__.py +18 -0
- hook_shipyard/__main__.py +6 -0
- hook_shipyard/cli.py +78 -0
- hook_shipyard/events.py +156 -0
- hook_shipyard/guard.py +104 -0
- hook_shipyard/manager.py +197 -0
- hook_shipyard/merge.py +156 -0
- hook_shipyard/migrations.py +45 -0
- hook_shipyard/models.py +138 -0
- hook_shipyard/native.py +335 -0
- hook_shipyard/providers.py +174 -0
- hook_shipyard/receipts.py +59 -0
- hook_shipyard/resources/migrations.json +4 -0
- hook_shipyard/storage.py +129 -0
- hook_shipyard/templates.py +82 -0
- hook_shipyard-0.1.0.dist-info/METADATA +123 -0
- hook_shipyard-0.1.0.dist-info/RECORD +20 -0
- hook_shipyard-0.1.0.dist-info/WHEEL +5 -0
- hook_shipyard-0.1.0.dist-info/entry_points.txt +2 -0
- hook_shipyard-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Manage native agent hooks independently of their consumers."""
|
|
2
|
+
|
|
3
|
+
from .manager import HookManager
|
|
4
|
+
from .models import HookSpec, JsonObject, JsonValue, Provider
|
|
5
|
+
from .providers import event_name, provider
|
|
6
|
+
|
|
7
|
+
__version__: str = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = [
|
|
10
|
+
"HookManager",
|
|
11
|
+
"HookSpec",
|
|
12
|
+
"JsonObject",
|
|
13
|
+
"JsonValue",
|
|
14
|
+
"Provider",
|
|
15
|
+
"__version__",
|
|
16
|
+
"event_name",
|
|
17
|
+
"provider",
|
|
18
|
+
]
|
hook_shipyard/cli.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Manage native hooks without running commands or enabling monitoring."""
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace
|
|
4
|
+
from json import dumps
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from sys import stdout
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .manager import HookManager
|
|
10
|
+
from .models import HookSpec
|
|
11
|
+
from .providers import event_name, provider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(arguments: list[str] | None = None) -> int:
|
|
15
|
+
"""Install, inspect or remove one consumer's native hooks.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
arguments: Optional explicit command-line arguments.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Zero after successful configuration; argparse errors return status 2.
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
parser: ArgumentParser = ArgumentParser(
|
|
25
|
+
prog="hook-shipyard",
|
|
26
|
+
description=__doc__,
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
29
|
+
parser.add_argument("action", choices=("install", "list", "uninstall"))
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--provider",
|
|
32
|
+
required=True,
|
|
33
|
+
choices=("claude-code", "codex", "cursor", "devin", "vscode"),
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("--root", required=True, type=Path)
|
|
36
|
+
parser.add_argument("--owner", required=True)
|
|
37
|
+
parser.add_argument("--project", action="store_true")
|
|
38
|
+
parser.add_argument("--event")
|
|
39
|
+
parser.add_argument("--phase")
|
|
40
|
+
parser.add_argument("--command")
|
|
41
|
+
parser.add_argument("--matcher")
|
|
42
|
+
parser.add_argument("--timeout", type=float)
|
|
43
|
+
parser.add_argument("--fail-closed", action="store_true")
|
|
44
|
+
parsed: Namespace = parser.parse_args(args=arguments)
|
|
45
|
+
if parsed.event and parsed.phase:
|
|
46
|
+
parser.error(message="Choose either --event or --phase")
|
|
47
|
+
try:
|
|
48
|
+
manager: HookManager = HookManager(
|
|
49
|
+
provider=provider(name=parsed.provider, project=parsed.project),
|
|
50
|
+
root=parsed.root,
|
|
51
|
+
owner=parsed.owner,
|
|
52
|
+
)
|
|
53
|
+
if parsed.action == "install":
|
|
54
|
+
if not (parsed.event or parsed.phase) or not parsed.command:
|
|
55
|
+
parser.error(
|
|
56
|
+
message="install requires --event/--phase and --command",
|
|
57
|
+
)
|
|
58
|
+
_ = manager.install(
|
|
59
|
+
specs=[
|
|
60
|
+
HookSpec(
|
|
61
|
+
event=parsed.event
|
|
62
|
+
or event_name(
|
|
63
|
+
host=provider(name=parsed.provider),
|
|
64
|
+
phase=parsed.phase,
|
|
65
|
+
),
|
|
66
|
+
command=parsed.command,
|
|
67
|
+
matcher=parsed.matcher,
|
|
68
|
+
timeout=parsed.timeout,
|
|
69
|
+
fail_closed=parsed.fail_closed,
|
|
70
|
+
),
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
elif parsed.action == "uninstall":
|
|
74
|
+
manager.uninstall()
|
|
75
|
+
_ = stdout.write(dumps(obj=manager.inspect(), indent=2) + "\n")
|
|
76
|
+
except (OSError, TypeError, ValueError) as error:
|
|
77
|
+
parser.error(message=str(object=error))
|
|
78
|
+
return 0
|
hook_shipyard/events.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Normalize native agent lifecycle payloads for any hook consumer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .models import JsonObject, JsonValue
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class NativeEvent:
|
|
15
|
+
"""Carry provider-independent hook input without consumer dependencies.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
provider: Native agent identifier.
|
|
19
|
+
name: Original native event name.
|
|
20
|
+
phase: Normalized lifecycle phase.
|
|
21
|
+
session: Native session or conversation identifier.
|
|
22
|
+
workspace: Preferred native workspace.
|
|
23
|
+
tool: Native tool name.
|
|
24
|
+
command: Shell command when supplied.
|
|
25
|
+
command_id: Correlation identifier, including deterministic fallback.
|
|
26
|
+
response: Native tool result, when present.
|
|
27
|
+
description: Original tool input description.
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
provider: str
|
|
32
|
+
name: str
|
|
33
|
+
phase: str
|
|
34
|
+
session: str
|
|
35
|
+
workspace: str
|
|
36
|
+
tool: str
|
|
37
|
+
command: str
|
|
38
|
+
command_id: str
|
|
39
|
+
response: JsonObject | None
|
|
40
|
+
description: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
44
|
+
class NativeReply:
|
|
45
|
+
"""Describe a consumer's effects without prescribing native JSON shapes.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
context: Optional context to append to the agent's prompt.
|
|
49
|
+
variables: Explicit environment additions supplied by the consumer.
|
|
50
|
+
command: Optional consumer-specific replacement shell command.
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
context: str = ""
|
|
55
|
+
variables: dict[str, str] = field(default_factory=dict)
|
|
56
|
+
command: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalize(*, provider: str, payload: JsonObject) -> NativeEvent:
|
|
60
|
+
"""Decode native fields into one consumer-neutral lifecycle contract.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
provider: Registered native provider.
|
|
64
|
+
payload: Validated native JSON object.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Normalized event, including empty identity for unscoped payloads.
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
name: str = scalar(value=payload.get("hook_event_name"))
|
|
71
|
+
session: str = scalar(value=payload.get("session_id")) or scalar(
|
|
72
|
+
value=payload.get("conversation_id"),
|
|
73
|
+
)
|
|
74
|
+
workspace: str = scalar(value=payload.get("cwd"))
|
|
75
|
+
roots: JsonValue = payload.get("workspace_roots")
|
|
76
|
+
if provider == "cursor" and isinstance(roots, list) and roots:
|
|
77
|
+
workspace = scalar(value=roots[0]) or workspace
|
|
78
|
+
tool_input: JsonValue = payload.get("tool_input")
|
|
79
|
+
inputs: JsonObject = tool_input if isinstance(tool_input, dict) else {}
|
|
80
|
+
command: str = (
|
|
81
|
+
scalar(value=inputs.get("command"))
|
|
82
|
+
or scalar(
|
|
83
|
+
value=inputs.get("cmd"),
|
|
84
|
+
)
|
|
85
|
+
or scalar(value=payload.get("command"))
|
|
86
|
+
)
|
|
87
|
+
command_id: str = scalar(value=payload.get("tool_use_id"))
|
|
88
|
+
if not command_id and command and provider == "codex":
|
|
89
|
+
command_id = (
|
|
90
|
+
"codex-"
|
|
91
|
+
+ sha256(
|
|
92
|
+
string=f"{session}\0{command}".encode(),
|
|
93
|
+
).hexdigest()[:16]
|
|
94
|
+
)
|
|
95
|
+
response: JsonValue = payload.get("tool_response")
|
|
96
|
+
return NativeEvent(
|
|
97
|
+
provider=provider,
|
|
98
|
+
name=name,
|
|
99
|
+
phase=phase(name=name),
|
|
100
|
+
session=session,
|
|
101
|
+
workspace=workspace,
|
|
102
|
+
tool=scalar(value=payload.get("tool_name", payload.get("toolName"))),
|
|
103
|
+
command=command,
|
|
104
|
+
command_id=command_id,
|
|
105
|
+
response=response if isinstance(response, dict) else None,
|
|
106
|
+
description=scalar(value=inputs.get("description")),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def phase(*, name: str) -> str:
|
|
111
|
+
"""Map native event spelling without adding consumer behavior.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
name: Native lifecycle event name.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Stable semantic phase or the original unknown name.
|
|
118
|
+
|
|
119
|
+
"""
|
|
120
|
+
names: dict[str, str] = {
|
|
121
|
+
"SessionStart": "start",
|
|
122
|
+
"sessionStart": "start",
|
|
123
|
+
"SessionEnd": "end",
|
|
124
|
+
"sessionEnd": "end",
|
|
125
|
+
"PreToolUse": "before_tool",
|
|
126
|
+
"preToolUse": "before_tool",
|
|
127
|
+
"beforeShellExecution": "before_tool",
|
|
128
|
+
"PostToolUse": "after_tool",
|
|
129
|
+
"postToolUse": "after_tool",
|
|
130
|
+
"afterShellExecution": "after_tool",
|
|
131
|
+
"PostToolUseFailure": "tool_failed",
|
|
132
|
+
"UserPromptSubmit": "prompt",
|
|
133
|
+
"userPromptSubmitted": "prompt",
|
|
134
|
+
"beforeSubmitPrompt": "prompt",
|
|
135
|
+
"Stop": "stop",
|
|
136
|
+
"stop": "stop",
|
|
137
|
+
"agentStop": "stop",
|
|
138
|
+
"afterAgentThought": "thinking",
|
|
139
|
+
"afterAgentResponse": "response",
|
|
140
|
+
}
|
|
141
|
+
return names.get(name, name)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def scalar(*, value: JsonValue) -> str:
|
|
145
|
+
"""Convert native scalar fields without stringifying structured data.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
value: Native JSON field.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Scalar text or an empty string.
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
return (
|
|
155
|
+
str(object=value) if isinstance(value, (str, bool, int, float)) else ""
|
|
156
|
+
)
|
hook_shipyard/guard.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Implement a standalone non-monitoring pre-tool policy for native hosts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import ArgumentParser, Namespace
|
|
6
|
+
from json import dumps, loads
|
|
7
|
+
from sys import stdin, stdout
|
|
8
|
+
from typing import TYPE_CHECKING, Final, cast, final
|
|
9
|
+
|
|
10
|
+
from .providers import provider
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .models import JsonObject, JsonValue, Provider
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
class Constants:
|
|
18
|
+
"""Bound policy input independently of any monitoring implementation.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
request_bytes: Maximum JSON input bytes.
|
|
22
|
+
reason: Fixed denial text, without reflecting sensitive input.
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
request_bytes: Final[int] = 1024 * 1024
|
|
27
|
+
reason: Final[str] = "Tool is denied by the configured hook policy"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_tool(
|
|
31
|
+
*,
|
|
32
|
+
host: Provider,
|
|
33
|
+
payload: JsonObject,
|
|
34
|
+
blocked: frozenset[str],
|
|
35
|
+
) -> JsonObject:
|
|
36
|
+
"""Emit a native denial for an explicitly blocked tool identifier.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
host: Native provider contract.
|
|
40
|
+
payload: Pre-tool JSON object from that provider.
|
|
41
|
+
blocked: Operator-selected exact tool names.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Native denial, or an empty object making no permission decision.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: When the payload lacks a tool identifier.
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
name: JsonValue = payload.get(
|
|
51
|
+
"toolName" if host.name == "vscode" else "tool_name",
|
|
52
|
+
)
|
|
53
|
+
if not isinstance(name, str) or not name:
|
|
54
|
+
msg: str = "Pre-tool input requires a native tool name"
|
|
55
|
+
raise ValueError(msg)
|
|
56
|
+
if name not in blocked:
|
|
57
|
+
return {}
|
|
58
|
+
if host.name == "cursor":
|
|
59
|
+
return {"permission": "deny", "user_message": Constants.reason}
|
|
60
|
+
if host.name == "devin":
|
|
61
|
+
return {"decision": "block", "reason": Constants.reason}
|
|
62
|
+
decision: JsonObject = {
|
|
63
|
+
"permissionDecision": "deny",
|
|
64
|
+
"permissionDecisionReason": Constants.reason,
|
|
65
|
+
}
|
|
66
|
+
if host.name == "vscode":
|
|
67
|
+
return decision
|
|
68
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", **decision}}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main(arguments: list[str] | None = None) -> int:
|
|
72
|
+
"""Run a bounded pre-tool policy without logs, monitoring or shell eval.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
arguments: Explicit CLI arguments, or process arguments by default.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Zero after a native response; malformed input exits through argparse.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
parser: ArgumentParser = ArgumentParser(description=__doc__)
|
|
82
|
+
parser.add_argument("--provider", required=True)
|
|
83
|
+
parser.add_argument("--deny-tool", required=True, action="append")
|
|
84
|
+
parsed: Namespace = parser.parse_args(args=arguments)
|
|
85
|
+
try:
|
|
86
|
+
raw: bytes = stdin.buffer.read(Constants.request_bytes + 1)
|
|
87
|
+
if len(raw) > Constants.request_bytes:
|
|
88
|
+
parser.error(message="Hook input exceeded its byte limit")
|
|
89
|
+
payload: JsonValue = cast(typ="JsonValue", val=loads(s=raw))
|
|
90
|
+
if not isinstance(payload, dict):
|
|
91
|
+
parser.error(message="Hook input must be a JSON object")
|
|
92
|
+
result: JsonObject = check_tool(
|
|
93
|
+
host=provider(name=parsed.provider),
|
|
94
|
+
payload=payload,
|
|
95
|
+
blocked=frozenset(parsed.deny_tool),
|
|
96
|
+
)
|
|
97
|
+
_ = stdout.write(dumps(obj=result) + "\n")
|
|
98
|
+
except (ValueError, OSError) as error:
|
|
99
|
+
parser.error(message=str(object=error))
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
raise SystemExit(main())
|
hook_shipyard/manager.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Manage owner-scoped hooks without importing a monitoring implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from re import fullmatch
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .merge import hooks_of, legacy_records, remove
|
|
10
|
+
from .migrations import suffixes
|
|
11
|
+
from .receipts import read, write
|
|
12
|
+
from .storage import load, locked, save
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .models import HookSpec, JsonObject, JsonValue, Provider
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HookManager:
|
|
22
|
+
"""Own native registrations for one independently named consumer."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
provider: Provider,
|
|
28
|
+
root: Path,
|
|
29
|
+
owner: str,
|
|
30
|
+
legacy_flag: str | None = None,
|
|
31
|
+
backup_suffix: str = ".hook-shipyard-bak",
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Select an explicit host, configuration root and consumer.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
provider: Native configuration adapter.
|
|
37
|
+
root: User home or repository root selected by the caller.
|
|
38
|
+
owner: Stable consumer identifier, such as district or formatter.
|
|
39
|
+
legacy_flag: Explicit legacy two-argument launcher migration.
|
|
40
|
+
backup_suffix: Consumer's existing backup filename suffix.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: When the owner is empty or unsafe.
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
if (
|
|
47
|
+
fullmatch(pattern=r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}", string=owner)
|
|
48
|
+
is None
|
|
49
|
+
):
|
|
50
|
+
msg: str = "Hook owner must be a short stable identifier"
|
|
51
|
+
raise ValueError(msg)
|
|
52
|
+
self._provider: Provider = provider
|
|
53
|
+
self._owner: str = owner
|
|
54
|
+
self._legacy_flag: str | None = legacy_flag
|
|
55
|
+
self._backup_suffix: str = backup_suffix
|
|
56
|
+
self.path: Path = root / provider.relative
|
|
57
|
+
self._receipt: Path = self.path.with_name(
|
|
58
|
+
name=self.path.name + ".hook-shipyard-owners",
|
|
59
|
+
)
|
|
60
|
+
self._legacy_receipts: tuple[Path, ...] = tuple(
|
|
61
|
+
self.path.with_name(name=self.path.name + suffix)
|
|
62
|
+
for suffix in suffixes(kind="receipts")
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def install(self, *, specs: Sequence[HookSpec]) -> Path:
|
|
66
|
+
"""Create or replace this owner's registrations idempotently.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
specs: Complete desired registrations for this consumer.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Native configuration path.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
records: list[JsonValue] = [
|
|
76
|
+
{"event": spec.event, "entry": self._provider.render(spec=spec)}
|
|
77
|
+
for spec in specs
|
|
78
|
+
]
|
|
79
|
+
self._update(records=records)
|
|
80
|
+
return self.path
|
|
81
|
+
|
|
82
|
+
def uninstall(self) -> None:
|
|
83
|
+
"""Remove only this owner's recorded commands, preserving others."""
|
|
84
|
+
if (
|
|
85
|
+
self.path.exists()
|
|
86
|
+
or self._receipt.exists()
|
|
87
|
+
or any(path.exists() for path in self._legacy_receipts)
|
|
88
|
+
):
|
|
89
|
+
self._update(records=[])
|
|
90
|
+
|
|
91
|
+
def inspect(self) -> JsonObject:
|
|
92
|
+
"""Read native configuration and ownership without executing hooks.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Provider, path, owner and current recorded registrations.
|
|
96
|
+
|
|
97
|
+
"""
|
|
98
|
+
return {
|
|
99
|
+
"provider": self._provider.name,
|
|
100
|
+
"path": str(object=self.path),
|
|
101
|
+
"owner": self._owner,
|
|
102
|
+
"registrations": self._owners().get(self._owner, []),
|
|
103
|
+
"hooks": hooks_of(document=load(path=self.path)),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def _update(self, *, records: list[JsonValue]) -> None:
|
|
107
|
+
"""Commit receipt intent before changing executable configuration.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
records: Desired owned event/entry pairs.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: When a provider version or registration conflicts.
|
|
114
|
+
TypeError: When the receipt is malformed.
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
with locked(path=self.path):
|
|
118
|
+
self._migrate()
|
|
119
|
+
document: JsonObject = load(path=self.path)
|
|
120
|
+
if self._provider.versioned and document.get("version", 1) != 1:
|
|
121
|
+
msg: str = "Unsupported native hook configuration version"
|
|
122
|
+
raise ValueError(msg)
|
|
123
|
+
owners: JsonObject = read(path=self._receipt)
|
|
124
|
+
previous: JsonValue = owners.get(self._owner, [])
|
|
125
|
+
if not isinstance(previous, list):
|
|
126
|
+
msg = "Hook ownership receipt must contain registration arrays"
|
|
127
|
+
raise TypeError(msg)
|
|
128
|
+
existing: JsonObject = hooks_of(document=document)
|
|
129
|
+
if self._owner not in owners and self._legacy_flag is not None:
|
|
130
|
+
previous = legacy_records(
|
|
131
|
+
hooks=existing,
|
|
132
|
+
flag=self._legacy_flag,
|
|
133
|
+
)
|
|
134
|
+
hooks: JsonObject = remove(hooks=existing, records=previous)
|
|
135
|
+
for record in records:
|
|
136
|
+
if isinstance(record, dict) and isinstance(
|
|
137
|
+
event := record.get("event"),
|
|
138
|
+
str,
|
|
139
|
+
):
|
|
140
|
+
entries: JsonValue = hooks.setdefault(event, [])
|
|
141
|
+
if isinstance(entries, list):
|
|
142
|
+
if record["entry"] in entries:
|
|
143
|
+
msg = (
|
|
144
|
+
"Identical hook owned by another registration"
|
|
145
|
+
)
|
|
146
|
+
raise ValueError(msg)
|
|
147
|
+
entries.append(deepcopy(x=record["entry"]))
|
|
148
|
+
if hooks or self._provider.versioned:
|
|
149
|
+
document["hooks"] = hooks
|
|
150
|
+
else:
|
|
151
|
+
_ = document.pop("hooks", None)
|
|
152
|
+
if self._provider.versioned:
|
|
153
|
+
document["version"] = 1
|
|
154
|
+
owners[self._owner] = previous + [
|
|
155
|
+
record for record in records if record not in previous
|
|
156
|
+
]
|
|
157
|
+
write(path=self._receipt, owners=owners)
|
|
158
|
+
|
|
159
|
+
save(
|
|
160
|
+
path=self.path,
|
|
161
|
+
data=document,
|
|
162
|
+
backup_suffix=self._backup_suffix,
|
|
163
|
+
)
|
|
164
|
+
owners[self._owner] = records
|
|
165
|
+
write(path=self._receipt, owners=owners)
|
|
166
|
+
|
|
167
|
+
def _owners(self) -> JsonObject:
|
|
168
|
+
"""Inspect either ledger without changing user configuration.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Existing ownership records.
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
if self._receipt.exists():
|
|
175
|
+
return read(path=self._receipt)
|
|
176
|
+
for path in self._legacy_receipts:
|
|
177
|
+
if path.exists():
|
|
178
|
+
return read(path=path)
|
|
179
|
+
return {}
|
|
180
|
+
|
|
181
|
+
def _migrate(self) -> None:
|
|
182
|
+
"""Rename a validated old ledger while both lock names are held.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
ValueError: When both ledgers exist; no ownership is discarded.
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
for legacy in self._legacy_receipts:
|
|
189
|
+
if not legacy.exists():
|
|
190
|
+
continue
|
|
191
|
+
if self._receipt.exists():
|
|
192
|
+
msg: str = (
|
|
193
|
+
"Two ownership ledgers exist; reconcile before editing"
|
|
194
|
+
)
|
|
195
|
+
raise ValueError(msg)
|
|
196
|
+
_ = read(path=legacy)
|
|
197
|
+
_ = legacy.replace(target=self._receipt)
|