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.
@@ -0,0 +1,9 @@
1
+ """plan_manager_client: first-class client library for the plan_manager JSON-RPC API.
2
+
3
+ Author: Vasiliy Zdanovskiy
4
+ email: vasilyvz@gmail.com
5
+ """
6
+
7
+ __version__ = "0.1.52"
8
+
9
+ __all__: list[str] = []
@@ -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
+ )