plan-manager-client 0.1.52__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.
- plan_manager_client/__init__.py +9 -0
- plan_manager_client/client.py +64 -0
- plan_manager_client/config.py +68 -0
- plan_manager_client/delivery/__init__.py +7 -0
- plan_manager_client/delivery/archive.py +140 -0
- plan_manager_client/delivery/atomicity.py +93 -0
- plan_manager_client/delivery/commit.py +113 -0
- plan_manager_client/delivery/destination.py +140 -0
- plan_manager_client/delivery/provenance.py +88 -0
- plan_manager_client/delivery/upload.py +118 -0
- plan_manager_client/dispatch.py +58 -0
- plan_manager_client/export_fetch_chunks.py +166 -0
- plan_manager_client/export_fetch_request.py +82 -0
- plan_manager_client/export_fetch_unpack.py +149 -0
- plan_manager_client/export_fetch_verify.py +101 -0
- plan_manager_client/facade_bug_project_mixin.py +146 -0
- plan_manager_client/facade_context_gate_mixin.py +126 -0
- plan_manager_client/facade_plan_hrs_mixin.py +162 -0
- plan_manager_client/facade_runtime_mixin.py +174 -0
- plan_manager_client/facade_step_graph_mixin.py +130 -0
- plan_manager_client/server_api.py +265 -0
- plan_manager_client-0.1.52.dist-info/METADATA +10 -0
- plan_manager_client-0.1.52.dist-info/RECORD +25 -0
- plan_manager_client-0.1.52.dist-info/WHEEL +5 -0
- plan_manager_client-0.1.52.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Composed full-surface facade client for the plan_manager JSON-RPC API.
|
|
2
|
+
|
|
3
|
+
Author: Vasiliy Zdanovskiy
|
|
4
|
+
email: vasilyvz@gmail.com
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mcp_proxy_adapter.client.jsonrpc_client.client import JsonRpcClient
|
|
12
|
+
|
|
13
|
+
from plan_manager_client.dispatch import _CommandDispatchMixin
|
|
14
|
+
from plan_manager_client.facade_bug_project_mixin import BugProjectCommandsMixin
|
|
15
|
+
from plan_manager_client.facade_context_gate_mixin import ContextGateCommandsMixin
|
|
16
|
+
from plan_manager_client.facade_plan_hrs_mixin import PlanHrsCommandsMixin
|
|
17
|
+
from plan_manager_client.facade_runtime_mixin import RuntimeCommandsMixin
|
|
18
|
+
from plan_manager_client.facade_step_graph_mixin import StepGraphCommandsMixin
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PlanManagerClient(
|
|
22
|
+
_CommandDispatchMixin,
|
|
23
|
+
PlanHrsCommandsMixin,
|
|
24
|
+
StepGraphCommandsMixin,
|
|
25
|
+
ContextGateCommandsMixin,
|
|
26
|
+
RuntimeCommandsMixin,
|
|
27
|
+
BugProjectCommandsMixin,
|
|
28
|
+
):
|
|
29
|
+
"""Full command-surface facade for the plan_manager JSON-RPC API.
|
|
30
|
+
|
|
31
|
+
Composes (HOLDS, never inherits) a
|
|
32
|
+
mcp_proxy_adapter.client.jsonrpc_client.client.JsonRpcClient on the private
|
|
33
|
+
attribute ``self._rpc``, which supplies transport (http/https/mTLS), token
|
|
34
|
+
authentication, queued-job polling, and chunked file transfer with sha256
|
|
35
|
+
verification and resume. Because the transport client is held rather than
|
|
36
|
+
inherited, the network interaction is fully hidden: the public surface of
|
|
37
|
+
this class is exactly one public async method per plan_manager command,
|
|
38
|
+
contributed by the internal _call dispatch coroutine (queued-command
|
|
39
|
+
auto-polling per queue_get_job_status semantics) from _CommandDispatchMixin
|
|
40
|
+
and the five command-family mixins, together covering every name in
|
|
41
|
+
plan_manager_client.server_api.COMMAND_NAMES. Construct directly with the
|
|
42
|
+
JsonRpcClient constructor arguments, or via
|
|
43
|
+
PlanManagerClient(**config.to_jsonrpc_kwargs()) using a
|
|
44
|
+
plan_manager_client.config.ClientConnectionConfig instance. Connections are
|
|
45
|
+
direct to the plan_manager server; this class performs no proxy call_server
|
|
46
|
+
routing.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, **jsonrpc_kwargs: Any) -> None:
|
|
50
|
+
"""Compose the underlying JsonRpcClient from JsonRpcClient constructor kwargs.
|
|
51
|
+
|
|
52
|
+
Accepts exactly the keyword arguments of
|
|
53
|
+
mcp_proxy_adapter.client.jsonrpc_client.client.JsonRpcClient (protocol,
|
|
54
|
+
host, port, token_header, token, cert, key, ca, check_hostname,
|
|
55
|
+
timeout), so PlanManagerClient(**config.to_jsonrpc_kwargs()) from a
|
|
56
|
+
plan_manager_client.config.ClientConnectionConfig works directly. The
|
|
57
|
+
constructed client is stored on the private attribute self._rpc and is
|
|
58
|
+
never exposed as a public method, keeping the public surface exactly the
|
|
59
|
+
canonical command methods.
|
|
60
|
+
"""
|
|
61
|
+
self._rpc = JsonRpcClient(**jsonrpc_kwargs)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["PlanManagerClient"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Direct-connection configuration for a single plan_manager_client server.
|
|
2
|
+
|
|
3
|
+
Author: Vasiliy Zdanovskiy
|
|
4
|
+
email: vasilyvz@gmail.com
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ClientConnectionConfig:
|
|
16
|
+
"""Direct-connection parameters for one JSON-RPC server.
|
|
17
|
+
|
|
18
|
+
Mirrors, field for field, the constructor of
|
|
19
|
+
mcp_proxy_adapter.client.jsonrpc_client.client.JsonRpcClient:
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
protocol: str = "http",
|
|
24
|
+
host: str = "127.0.0.1",
|
|
25
|
+
port: int = 8080,
|
|
26
|
+
token_header: Optional[str] = None,
|
|
27
|
+
token: Optional[str] = None,
|
|
28
|
+
cert: Optional[str] = None,
|
|
29
|
+
key: Optional[str] = None,
|
|
30
|
+
ca: Optional[str] = None,
|
|
31
|
+
check_hostname: bool = False,
|
|
32
|
+
timeout: Optional[float] = None,
|
|
33
|
+
) -> None: ...
|
|
34
|
+
|
|
35
|
+
Instances are immutable. Use to_jsonrpc_kwargs() to build the keyword
|
|
36
|
+
arguments for JsonRpcClient(**kwargs) or for any other client built on the
|
|
37
|
+
same constructor shape (for example a code-analysis-client construction
|
|
38
|
+
helper), so one configuration entity serves either server.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
protocol: str = "http"
|
|
42
|
+
host: str = "127.0.0.1"
|
|
43
|
+
port: int = 8080
|
|
44
|
+
token_header: Optional[str] = None
|
|
45
|
+
token: Optional[str] = None
|
|
46
|
+
cert: Optional[str] = None
|
|
47
|
+
key: Optional[str] = None
|
|
48
|
+
ca: Optional[str] = None
|
|
49
|
+
check_hostname: bool = False
|
|
50
|
+
timeout: Optional[float] = None
|
|
51
|
+
|
|
52
|
+
def to_jsonrpc_kwargs(self) -> dict[str, Any]:
|
|
53
|
+
"""Return constructor kwargs for JsonRpcClient(**kwargs)."""
|
|
54
|
+
return {
|
|
55
|
+
"protocol": self.protocol,
|
|
56
|
+
"host": self.host,
|
|
57
|
+
"port": self.port,
|
|
58
|
+
"token_header": self.token_header,
|
|
59
|
+
"token": self.token,
|
|
60
|
+
"cert": self.cert,
|
|
61
|
+
"key": self.key,
|
|
62
|
+
"ca": self.ca,
|
|
63
|
+
"check_hostname": self.check_hostname,
|
|
64
|
+
"timeout": self.timeout,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["ClientConnectionConfig"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Delivery module boundary for the plan_manager client library.
|
|
2
|
+
|
|
3
|
+
Author: Vasiliy Zdanovskiy
|
|
4
|
+
email: vasilyvz@gmail.com
|
|
5
|
+
|
|
6
|
+
This sub-package establishes the two-path delivery model (C-001) structurally: the module boundary where Path A (a code-analysis project specified; delivered by a later G-005 branch) and Path B (no project specified; delivered by a later G-004 branch) each add their own composition module, both consuming the plan_manager_client facade (client/plan_manager_client/client.py) and, for Path A, the code-analysis-client dependency. This package intentionally defines no dataclasses, function signatures, or interface contracts here: those are the design decisions of the G-004 and G-005 branches that will populate this sub-package, per the L1 ruling (decision comment 10eba9c2 resolving escalation 4f4a04bb) that this branch (G-003) must make the module boundary explicit without pre-empting their design.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Path A verified export tree materialization from the archive.
|
|
2
|
+
|
|
3
|
+
Verifies the retrieved export archive against its declared sha256 BEFORE
|
|
4
|
+
unpacking anything (DeliveryIntegrityContract, MRS concept C-007), then
|
|
5
|
+
unpacks the gzip-compressed tar in memory into ordered tree entries that
|
|
6
|
+
preserve every original name and relative position (ExportArchive, MRS
|
|
7
|
+
concept C-016). Every entry is validated before any entry is materialized,
|
|
8
|
+
so a refusal leaves nothing behind. Nothing enumerates an export, so the
|
|
9
|
+
archive is the only way Path A obtains a whole tree. Produces no local
|
|
10
|
+
files.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
import hashlib
|
|
15
|
+
import io
|
|
16
|
+
import tarfile
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ArchiveIntegrityError(Exception):
|
|
21
|
+
"""Raised when the export archive does not match its declared sha256.
|
|
22
|
+
|
|
23
|
+
Raised by :func:`materialize_tree` before any unpacking is attempted,
|
|
24
|
+
per DeliveryIntegrityContract (MRS concept C-007). Nothing is unpacked
|
|
25
|
+
and nothing reaches any destination when this is raised.
|
|
26
|
+
"""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ArchiveEntryRefusedError(Exception):
|
|
31
|
+
"""Raised when an archive entry is refused during validation.
|
|
32
|
+
|
|
33
|
+
Raised by :func:`materialize_tree` for an entry whose stored path is
|
|
34
|
+
absolute or contains a '..' segment, or which is neither a regular
|
|
35
|
+
file nor a directory. Per ExportArchive (MRS concept C-016) every
|
|
36
|
+
entry is validated before any entry is materialized, so a refusal
|
|
37
|
+
leaves nothing behind.
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class TreeEntry:
|
|
44
|
+
"""One file of the verified export tree, in memory.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
relative_path: The entry's POSIX path relative to the plan export
|
|
48
|
+
root, exactly as stored in the archive (for example
|
|
49
|
+
'spec.yaml' or 'G-001-foo/T-001-bar/atomic_steps/A-001-baz.yaml').
|
|
50
|
+
content: The entry's unpacked bytes, byte-for-byte as archived.
|
|
51
|
+
sha256: The hex sha256 digest computed over content.
|
|
52
|
+
"""
|
|
53
|
+
relative_path: str
|
|
54
|
+
content: bytes
|
|
55
|
+
sha256: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_member(member: tarfile.TarInfo) -> None:
|
|
59
|
+
"""Validate one archive member, refusing it if it is unsafe.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
member: The archive member to validate.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ArchiveEntryRefusedError: If member is neither a regular file nor
|
|
66
|
+
a directory, or if member.name starts with '/' or any
|
|
67
|
+
'/'-separated segment of member.name equals '..'.
|
|
68
|
+
"""
|
|
69
|
+
if not member.isfile() and not member.isdir():
|
|
70
|
+
raise ArchiveEntryRefusedError(
|
|
71
|
+
f"archive entry is neither a regular file nor a directory: {member.name!r}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if member.name.startswith('/') or '..' in member.name.split('/'):
|
|
75
|
+
raise ArchiveEntryRefusedError(
|
|
76
|
+
f"archive entry escapes the destination: {member.name!r}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def materialize_tree(
|
|
81
|
+
archive_bytes: bytes,
|
|
82
|
+
declared_archive_sha256: str,
|
|
83
|
+
) -> list[TreeEntry]:
|
|
84
|
+
"""Verify the archive, then unpack its tree in memory.
|
|
85
|
+
|
|
86
|
+
Validation is exhaustive and precedes materialization: every member is
|
|
87
|
+
validated before any member's bytes are read, so a refusal returns
|
|
88
|
+
nothing and leaves nothing behind (ExportArchive, MRS concept C-016).
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
archive_bytes: The already-retrieved archive content, a
|
|
92
|
+
gzip-compressed tar.
|
|
93
|
+
declared_archive_sha256: The archive's declared whole-archive
|
|
94
|
+
sha256 hex digest.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The tree entries in the order the archive stores them, each
|
|
98
|
+
carrying its export-root-relative path, its unpacked content, and
|
|
99
|
+
the sha256 computed over that content. Directory members are
|
|
100
|
+
skipped rather than returned.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ArchiveIntegrityError: If sha256(archive_bytes) does not equal
|
|
104
|
+
declared_archive_sha256. Raised before any unpacking; nothing
|
|
105
|
+
is unpacked.
|
|
106
|
+
ArchiveEntryRefusedError: If ANY member is refused by
|
|
107
|
+
_validate_member. Raised during the validation pass, before
|
|
108
|
+
any member's bytes are read, so no entry is returned.
|
|
109
|
+
"""
|
|
110
|
+
computed = hashlib.sha256(archive_bytes).hexdigest()
|
|
111
|
+
|
|
112
|
+
if computed != declared_archive_sha256:
|
|
113
|
+
raise ArchiveIntegrityError(
|
|
114
|
+
f"archive digest mismatch: expected {declared_archive_sha256}, computed {computed}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tar:
|
|
118
|
+
# Validation pass: exhaustive check before any materialization
|
|
119
|
+
members = tar.getmembers()
|
|
120
|
+
for member in members:
|
|
121
|
+
_validate_member(member)
|
|
122
|
+
|
|
123
|
+
# Materialization pass: extract bytes and build entries
|
|
124
|
+
result: list[TreeEntry] = []
|
|
125
|
+
for member in members:
|
|
126
|
+
if member.isdir():
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
extracted_file = tar.extractfile(member)
|
|
130
|
+
content = extracted_file.read()
|
|
131
|
+
entry_digest = hashlib.sha256(content).hexdigest()
|
|
132
|
+
result.append(
|
|
133
|
+
TreeEntry(
|
|
134
|
+
relative_path=member.name,
|
|
135
|
+
content=content,
|
|
136
|
+
sha256=entry_digest,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return result
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Path A failure atomicity and partial-write outcome tracking.
|
|
2
|
+
|
|
3
|
+
Assembles the delivery outcome record naming exactly which destination
|
|
4
|
+
paths were written and whether a commit was made, distinguishing every
|
|
5
|
+
abort point of the Path A composition, realizing DeliveryFailureAtomicity
|
|
6
|
+
(MRS concept C-011). An archive-verification or archive-entry-refusal
|
|
7
|
+
abort leaves nothing behind, so the record cannot express either of them
|
|
8
|
+
alongside written paths.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Literal, Optional
|
|
15
|
+
|
|
16
|
+
FailurePoint = Literal["archive_verification", "archive_entry_refused", "digest_verification", "upload", "commit"]
|
|
17
|
+
# The Path A composition stage at which a delivery failed, when it failed (MRS concept C-011).
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class DeliveryOutcome:
|
|
22
|
+
"""Outcome of one Path A delivery attempt.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
written_paths: Destination paths successfully written to the CA
|
|
26
|
+
project during this attempt, in delivery order, each carrying
|
|
27
|
+
its entry's relative position beneath the destination
|
|
28
|
+
subdirectory.
|
|
29
|
+
commit_made: True when a git commit was made for this attempt.
|
|
30
|
+
commit_hash: The git commit hash, when commit_made is True;
|
|
31
|
+
otherwise None.
|
|
32
|
+
failure_point: The composition stage at which the delivery failed,
|
|
33
|
+
or None when the delivery completed successfully. A failure at
|
|
34
|
+
'archive_verification' or 'archive_entry_refused' always
|
|
35
|
+
carries an empty written_paths: the archive was rejected or
|
|
36
|
+
the whole tree was refused before any entry was materialized,
|
|
37
|
+
so nothing was written and nothing was left behind.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
written_paths: list[str] = field(default_factory=list)
|
|
41
|
+
commit_made: bool = False
|
|
42
|
+
commit_hash: Optional[str] = None
|
|
43
|
+
failure_point: Optional[FailurePoint] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_VALID_FAILURE_POINTS: tuple[str, ...] = ("archive_verification", "archive_entry_refused", "digest_verification", "upload", "commit")
|
|
47
|
+
|
|
48
|
+
_NOTHING_WRITTEN_FAILURE_POINTS: tuple[str, ...] = ("archive_verification", "archive_entry_refused")
|
|
49
|
+
# Abort points that precede every write: the archive never verified, or the whole tree was refused before any entry was materialized. Both leave nothing behind, so neither can accompany a non-empty written_paths.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_delivery_outcome(
|
|
53
|
+
written_paths: list[str],
|
|
54
|
+
commit_hash: Optional[str],
|
|
55
|
+
failure_point: Optional[FailurePoint] = None,
|
|
56
|
+
) -> DeliveryOutcome:
|
|
57
|
+
"""Assemble a DeliveryOutcome from one Path A delivery attempt's results.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
written_paths: Destination paths successfully written during this
|
|
61
|
+
attempt, in delivery order.
|
|
62
|
+
commit_hash: The git commit hash if a commit was made, else None.
|
|
63
|
+
failure_point: The stage at which the delivery failed, or None for
|
|
64
|
+
a fully successful delivery.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A DeliveryOutcome with commit_made set to (commit_hash is not
|
|
68
|
+
None), and written_paths, commit_hash, failure_point carried
|
|
69
|
+
through unchanged (written_paths copied, not aliased).
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If failure_point is not None and not one of
|
|
73
|
+
'archive_verification', 'archive_entry_refused',
|
|
74
|
+
'digest_verification', 'upload', 'commit'; or if
|
|
75
|
+
failure_point is 'archive_verification' or
|
|
76
|
+
'archive_entry_refused' while written_paths is non-empty,
|
|
77
|
+
which is an impossible outcome because both abort points
|
|
78
|
+
precede every write and leave nothing behind.
|
|
79
|
+
"""
|
|
80
|
+
if failure_point is not None and failure_point not in _VALID_FAILURE_POINTS:
|
|
81
|
+
raise ValueError(f"invalid failure_point: {failure_point!r}")
|
|
82
|
+
|
|
83
|
+
if failure_point in _NOTHING_WRITTEN_FAILURE_POINTS and len(written_paths) > 0:
|
|
84
|
+
raise ValueError(f"failure_point {failure_point!r} leaves nothing behind but written_paths is non-empty: {list(written_paths)!r}")
|
|
85
|
+
|
|
86
|
+
commit_made = commit_hash is not None
|
|
87
|
+
|
|
88
|
+
return DeliveryOutcome(
|
|
89
|
+
written_paths=list(written_paths),
|
|
90
|
+
commit_made=commit_made,
|
|
91
|
+
commit_hash=commit_hash,
|
|
92
|
+
failure_point=failure_point,
|
|
93
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Path A explicit git staging and commit composition.
|
|
2
|
+
|
|
3
|
+
After the tree upload phase, explicitly stages the written destination
|
|
4
|
+
paths and issues an explicit commit through the CA service's own client
|
|
5
|
+
(CodeAnalysisClientDependency, MRS concept C-015), independent of the CA
|
|
6
|
+
service's commit-on-write configuration, realizing the commit phase of
|
|
7
|
+
PathACodeAnalysisDelivery (MRS concept C-003) and the deterministic-message
|
|
8
|
+
requirement of DeliveryFailureAtomicity (MRS concept C-011).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional
|
|
14
|
+
from code_analysis_client.client import CodeAnalysisAsyncClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CommitReceipt:
|
|
19
|
+
"""Outcome of the explicit git staging and commit composition.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
commit_hash: The git commit hash the CA client returned, or None
|
|
23
|
+
when no commit was made (empty written_paths).
|
|
24
|
+
commit_message: The deterministic commit message text used, or
|
|
25
|
+
an empty string when no commit was made.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
commit_hash: Optional[str]
|
|
29
|
+
commit_message: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_commit_message(
|
|
33
|
+
plan_name: str,
|
|
34
|
+
export_revision_uuid: str,
|
|
35
|
+
per_entry_digest_set: dict[str, str],
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Build the deterministic Path A commit message.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
plan_name: Name of the plan being delivered.
|
|
41
|
+
export_revision_uuid: Revision uuid of the export being delivered.
|
|
42
|
+
per_entry_digest_set: Mapping of each delivered tree entry's path
|
|
43
|
+
relative to the plan export root to its sha256 hex digest.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A deterministic multi-line commit message: a header line naming
|
|
47
|
+
plan_name and export_revision_uuid, followed by one line per entry
|
|
48
|
+
of per_entry_digest_set sorted by relative path ascending (str
|
|
49
|
+
order), each formatted as two spaces then
|
|
50
|
+
'{relative_path}: sha256:{digest}'. Sorting makes the message
|
|
51
|
+
reproducible across retried deliveries regardless of input dict
|
|
52
|
+
ordering.
|
|
53
|
+
"""
|
|
54
|
+
header = f"plan_manager export delivery: {plan_name} (revision {export_revision_uuid})"
|
|
55
|
+
lines = [header] + [f" {relative_path}: sha256:{digest}" for relative_path, digest in sorted(per_entry_digest_set.items())]
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def stage_and_commit(
|
|
60
|
+
ca_client: CodeAnalysisAsyncClient,
|
|
61
|
+
resolved_project_id: str,
|
|
62
|
+
written_paths: list[str],
|
|
63
|
+
plan_name: str,
|
|
64
|
+
export_revision_uuid: str,
|
|
65
|
+
per_entry_digest_set: dict[str, str],
|
|
66
|
+
) -> CommitReceipt:
|
|
67
|
+
"""Explicitly stage and commit the written Path A tree.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
ca_client: An already-connected CodeAnalysisAsyncClient instance
|
|
71
|
+
(MRS concept C-015); this function does not construct or
|
|
72
|
+
close it.
|
|
73
|
+
resolved_project_id: The CA project_id the tree was written into.
|
|
74
|
+
written_paths: Destination paths successfully uploaded, each
|
|
75
|
+
carrying its entry's relative position beneath the destination
|
|
76
|
+
subdirectory.
|
|
77
|
+
plan_name: Name of the plan being delivered.
|
|
78
|
+
export_revision_uuid: Revision uuid of the export being delivered.
|
|
79
|
+
per_entry_digest_set: Mapping of each delivered tree entry's
|
|
80
|
+
export-root-relative path to its sha256 digest.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A CommitReceipt. When written_paths is empty, CommitReceipt(None, '')
|
|
84
|
+
is returned without any CA git call. Otherwise the CA client's
|
|
85
|
+
git_add is called with resolved_project_id and written_paths, then
|
|
86
|
+
its git_commit is called with resolved_project_id and the message
|
|
87
|
+
from build_commit_message; the returned CommitReceipt carries the
|
|
88
|
+
commit hash the CA client reports and the message used.
|
|
89
|
+
"""
|
|
90
|
+
if len(written_paths) == 0:
|
|
91
|
+
return CommitReceipt(commit_hash=None, commit_message="")
|
|
92
|
+
|
|
93
|
+
await ca_client.commands.git_add(project_id=resolved_project_id, paths=written_paths)
|
|
94
|
+
|
|
95
|
+
message = build_commit_message(plan_name, export_revision_uuid, per_entry_digest_set)
|
|
96
|
+
|
|
97
|
+
result = await ca_client.commands.git_commit(project_id=resolved_project_id, message=message)
|
|
98
|
+
|
|
99
|
+
def extract_commit_hash(result_obj):
|
|
100
|
+
# Try attribute access first
|
|
101
|
+
if hasattr(result_obj, 'commit_hash'):
|
|
102
|
+
return result_obj.commit_hash
|
|
103
|
+
# Try dict-like access
|
|
104
|
+
try:
|
|
105
|
+
return result_obj["commit_hash"]
|
|
106
|
+
except (TypeError, KeyError):
|
|
107
|
+
pass
|
|
108
|
+
# Fall back to str
|
|
109
|
+
return str(result_obj)
|
|
110
|
+
|
|
111
|
+
commit_hash = extract_commit_hash(result)
|
|
112
|
+
|
|
113
|
+
return CommitReceipt(commit_hash=commit_hash, commit_message=message)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Path A destination resolution: CA project binding and tree paths.
|
|
2
|
+
|
|
3
|
+
Resolves the target code-analysis project identity under SHARED PROJECT
|
|
4
|
+
IDENTITY (DeliveryDestinationAddressing, MRS concept C-006) and maps each
|
|
5
|
+
export tree entry to its destination path inside the project's
|
|
6
|
+
documentation area, preserving the entry's relative position beneath the
|
|
7
|
+
destination subdirectory so the delivered tree reproduces the export
|
|
8
|
+
layout (PathACodeAnalysisDelivery, MRS concept C-003).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NoBoundProjectError(Exception):
|
|
17
|
+
"""Raised when a Path A delivery is attempted with no bound CA project.
|
|
18
|
+
|
|
19
|
+
Raised by :func:`resolve_destination` when both ``plan_project_binding``
|
|
20
|
+
and ``project_id_override`` are ``None``: SHARED PROJECT IDENTITY (C-006)
|
|
21
|
+
requires the plan to carry a bound primary code-analysis project_id, or
|
|
22
|
+
an explicit override, before a Path A call may proceed.
|
|
23
|
+
"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DestinationPathTraversalError(Exception):
|
|
28
|
+
"""Raised when a resolved destination path escapes the project root.
|
|
29
|
+
|
|
30
|
+
Raised by :func:`resolve_destination` when the destination subdirectory
|
|
31
|
+
or a per-entry destination path is absolute or contains a '..' path
|
|
32
|
+
segment, per the traversal-refusal rule of DeliveryDestinationAddressing
|
|
33
|
+
(MRS concept C-006).
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
DEFAULT_DESTINATION_SUBDIRECTORY_TEMPLATE: str = "docs/plan-exports/{plan_name}/"
|
|
39
|
+
# Default destination subdirectory template (MRS concept C-006); it roots the delivered tree and is formatted with plan_name when the caller supplies no override.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ResolvedDestination:
|
|
44
|
+
"""Resolved CA project and per-entry destination paths for Path A.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
resolved_project_id: The code-analysis project_id the delivery
|
|
48
|
+
will write into.
|
|
49
|
+
resolved_destination_subdirectory: The destination documentation
|
|
50
|
+
subdirectory rooting the delivered tree, project-relative
|
|
51
|
+
POSIX, always ending in a single trailing '/'.
|
|
52
|
+
resolved_destination_paths: Ordered per-entry project-relative
|
|
53
|
+
POSIX destination paths, one per entry of tree_relative_paths
|
|
54
|
+
in the same order, each preserving that entry's relative
|
|
55
|
+
position beneath the subdirectory.
|
|
56
|
+
"""
|
|
57
|
+
resolved_project_id: str
|
|
58
|
+
resolved_destination_subdirectory: str
|
|
59
|
+
resolved_destination_paths: list[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _reject_traversal(path: str) -> None:
|
|
63
|
+
"""Raise DestinationPathTraversalError for an unsafe POSIX path.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: A POSIX path string to validate.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
DestinationPathTraversalError: If path starts with '/' or any
|
|
70
|
+
'/'-separated segment of path equals '..'.
|
|
71
|
+
"""
|
|
72
|
+
if path.startswith('/') or '..' in path.split('/'):
|
|
73
|
+
raise DestinationPathTraversalError(f"destination path escapes the project root: {path!r}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_destination(
|
|
77
|
+
plan_name: str,
|
|
78
|
+
tree_relative_paths: list[str],
|
|
79
|
+
plan_project_binding: Optional[str],
|
|
80
|
+
project_id_override: Optional[str] = None,
|
|
81
|
+
destination_subdirectory_override: Optional[str] = None,
|
|
82
|
+
) -> ResolvedDestination:
|
|
83
|
+
"""Resolve the CA project and per-entry destination paths for Path A.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
plan_name: Name of the plan being delivered; used to format the
|
|
87
|
+
default destination subdirectory when no override is supplied.
|
|
88
|
+
tree_relative_paths: The export tree entries' POSIX paths relative
|
|
89
|
+
to the plan export root, in tree order. These are nested paths
|
|
90
|
+
such as 'spec.yaml' or
|
|
91
|
+
'G-001-x/T-001-y/atomic_steps/A-001-z.yaml'; their relative
|
|
92
|
+
position is preserved into the destination.
|
|
93
|
+
plan_project_binding: The plan's bound primary code-analysis
|
|
94
|
+
project_id, or None when the plan has no bound project.
|
|
95
|
+
project_id_override: An explicit code-analysis project_id supplied
|
|
96
|
+
by the caller; takes precedence over plan_project_binding when
|
|
97
|
+
not None.
|
|
98
|
+
destination_subdirectory_override: An explicit destination
|
|
99
|
+
documentation subdirectory rooting the delivered tree; when
|
|
100
|
+
None, the default template formatted with plan_name is used.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A ResolvedDestination carrying the resolved project_id, the
|
|
104
|
+
resolved destination subdirectory (normalized to end with a single
|
|
105
|
+
trailing '/'), and the per-entry destination paths in the same
|
|
106
|
+
order as tree_relative_paths.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
NoBoundProjectError: If both plan_project_binding and
|
|
110
|
+
project_id_override are None.
|
|
111
|
+
DestinationPathTraversalError: If the resolved destination
|
|
112
|
+
subdirectory is unsafe per _reject_traversal, or if any entry
|
|
113
|
+
of tree_relative_paths is itself unsafe per _reject_traversal,
|
|
114
|
+
or if any resolved destination path is unsafe per
|
|
115
|
+
_reject_traversal.
|
|
116
|
+
"""
|
|
117
|
+
resolved_project_id = project_id_override if project_id_override is not None else plan_project_binding
|
|
118
|
+
|
|
119
|
+
if resolved_project_id is None:
|
|
120
|
+
raise NoBoundProjectError("Path A delivery requires a bound or overridden CA project_id")
|
|
121
|
+
|
|
122
|
+
subdirectory = destination_subdirectory_override if destination_subdirectory_override else DEFAULT_DESTINATION_SUBDIRECTORY_TEMPLATE.format(plan_name=plan_name)
|
|
123
|
+
|
|
124
|
+
if not subdirectory.endswith('/'):
|
|
125
|
+
subdirectory = subdirectory + '/'
|
|
126
|
+
|
|
127
|
+
_reject_traversal(subdirectory)
|
|
128
|
+
|
|
129
|
+
destination_paths = []
|
|
130
|
+
for relative_path in tree_relative_paths:
|
|
131
|
+
_reject_traversal(relative_path)
|
|
132
|
+
candidate = subdirectory + relative_path
|
|
133
|
+
_reject_traversal(candidate)
|
|
134
|
+
destination_paths.append(candidate)
|
|
135
|
+
|
|
136
|
+
return ResolvedDestination(
|
|
137
|
+
resolved_project_id=resolved_project_id,
|
|
138
|
+
resolved_destination_subdirectory=subdirectory,
|
|
139
|
+
resolved_destination_paths=destination_paths
|
|
140
|
+
)
|