plan-manager-client-dev 0.2.186__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 +10 -0
- plan_manager_client/_version.py +58 -0
- plan_manager_client/authoring_diagnostics.py +137 -0
- plan_manager_client/client.py +75 -0
- plan_manager_client/config.py +320 -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 +380 -0
- plan_manager_client/export_fetch_chunks.py +166 -0
- plan_manager_client/export_fetch_request.py +88 -0
- plan_manager_client/export_fetch_unpack.py +149 -0
- plan_manager_client/export_fetch_verify.py +101 -0
- plan_manager_client/facade_agent_config_mixin.py +224 -0
- plan_manager_client/facade_bug_project_mixin.py +202 -0
- plan_manager_client/facade_context_gate_mixin.py +303 -0
- plan_manager_client/facade_entity_common_mixin.py +38 -0
- plan_manager_client/facade_hrs_file_mixin.py +206 -0
- plan_manager_client/facade_plan_hrs_mixin.py +200 -0
- plan_manager_client/facade_runtime_mixin/__init__.py +11 -0
- plan_manager_client/facade_runtime_mixin/authoring_part.py +442 -0
- plan_manager_client/facade_runtime_mixin/facade.py +380 -0
- plan_manager_client/facade_runtime_mixin/overlay_part.py +192 -0
- plan_manager_client/facade_step_graph_mixin.py +158 -0
- plan_manager_client/id_rule.py +115 -0
- plan_manager_client/info_walker.py +133 -0
- plan_manager_client/scenario_runtime.py +65 -0
- plan_manager_client/server_api.py +427 -0
- plan_manager_client/server_api_mutating.py +105 -0
- plan_manager_client_dev-0.2.186.dist-info/METADATA +10 -0
- plan_manager_client_dev-0.2.186.dist-info/RECORD +37 -0
- plan_manager_client_dev-0.2.186.dist-info/WHEEL +5 -0
- plan_manager_client_dev-0.2.186.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Step-core and graph/dependency command-family facade mixin.
|
|
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
|
+
|
|
12
|
+
class StepGraphCommandsMixin:
|
|
13
|
+
"""One method per plan_manager command in the step-core and graph/dependency family.
|
|
14
|
+
|
|
15
|
+
Assumes it is mixed into a class that also inherits
|
|
16
|
+
plan_manager_client.dispatch._CommandDispatchMixin, which supplies the
|
|
17
|
+
coroutine async def _call(self, command: str, params: dict[str, Any] | None = None) -> Any
|
|
18
|
+
used by every method below.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
async def step_get(self, **params: Any) -> Any:
|
|
22
|
+
"""Return one step of a plan identified by step (its id, a uuid4), with resolved parent context."""
|
|
23
|
+
return await self._call("step_get", params)
|
|
24
|
+
|
|
25
|
+
async def step_tree(self, **params: Any) -> Any:
|
|
26
|
+
"""Return a paginated page of the plan's full step tree as a flat, sorted list with statuses."""
|
|
27
|
+
return await self._call("step_tree", params)
|
|
28
|
+
|
|
29
|
+
async def step_create(self, **params: Any) -> Any:
|
|
30
|
+
"""Scaffold a new step under a plan's declarative level schema."""
|
|
31
|
+
return await self._call("step_create", params)
|
|
32
|
+
|
|
33
|
+
async def step_update(self, **params: Any) -> Any:
|
|
34
|
+
"""Patch level-specific fields of an existing step, re-validating touched references."""
|
|
35
|
+
return await self._call("step_update", params)
|
|
36
|
+
|
|
37
|
+
async def step_move(self, **params: Any) -> Any:
|
|
38
|
+
"""Move a step to a new parent, rewriting every reference to it in one operation."""
|
|
39
|
+
return await self._call("step_move", params)
|
|
40
|
+
|
|
41
|
+
async def step_delete(self, **params: Any) -> Any:
|
|
42
|
+
"""Delete a step, with a dry-run impact preview enabled by default."""
|
|
43
|
+
return await self._call("step_delete", params)
|
|
44
|
+
|
|
45
|
+
async def step_set_status(self, **params: Any) -> Any:
|
|
46
|
+
"""Transition a step's status, refusing illegal transitions and the edit of a frozen step."""
|
|
47
|
+
return await self._call("step_set_status", params)
|
|
48
|
+
|
|
49
|
+
async def step_transition(self, **params: Any) -> Any:
|
|
50
|
+
"""Transition one step or a scope of steps through the authoring lifecycle."""
|
|
51
|
+
return await self._call("step_transition", params)
|
|
52
|
+
|
|
53
|
+
async def step_runtime_get(self, **params: Any) -> Any:
|
|
54
|
+
"""Return runtime parameters for one plan step."""
|
|
55
|
+
return await self._call("step_runtime_get", params)
|
|
56
|
+
|
|
57
|
+
async def step_runtime_report(self, **params: Any) -> Any:
|
|
58
|
+
"""Merge runtime parameters for one plan step."""
|
|
59
|
+
return await self._call("step_runtime_report", params)
|
|
60
|
+
|
|
61
|
+
async def step_runtime_list(self, **params: Any) -> Any:
|
|
62
|
+
"""List a paginated, artifact_path-sorted page of runtime parameters for plan steps in a scope."""
|
|
63
|
+
return await self._call("step_runtime_list", params)
|
|
64
|
+
|
|
65
|
+
async def step_list(self, **params: Any) -> Any:
|
|
66
|
+
"""Return a flat, paginated listing of a plan's steps with full step fields, filterable by level, parent, status, and target_file."""
|
|
67
|
+
return await self._call("step_list", params)
|
|
68
|
+
|
|
69
|
+
async def step_search(self, **params: Any) -> Any:
|
|
70
|
+
"""Search step content for an exact substring or regex, scoped to a plan or one branch."""
|
|
71
|
+
return await self._call("step_search", params)
|
|
72
|
+
|
|
73
|
+
async def files_report(self, **params: Any) -> Any:
|
|
74
|
+
"""Return the target_file to writer-steps matrix for a plan scope, with ordering-conflict detection."""
|
|
75
|
+
return await self._call("files_report", params)
|
|
76
|
+
|
|
77
|
+
async def namespace_view(self, **params: Any) -> Any:
|
|
78
|
+
"""Return a paginated page of one step's computed inherited namespace (ancestor-chain fields.names, optionally CA-probe seeded)."""
|
|
79
|
+
return await self._call("namespace_view", params)
|
|
80
|
+
|
|
81
|
+
async def namespace_rename_suggest(self, **params: Any) -> Any:
|
|
82
|
+
"""Propose the full down-tree rename/signature-change cascade of one fields.names declaration entry; read-only."""
|
|
83
|
+
return await self._call("namespace_rename_suggest", params)
|
|
84
|
+
|
|
85
|
+
async def namespace_rename_apply(self, **params: Any) -> Any:
|
|
86
|
+
"""Apply the rename/signature-change cascade atomically as one revision, or dry-run it (the default)."""
|
|
87
|
+
return await self._call("namespace_rename_apply", params)
|
|
88
|
+
|
|
89
|
+
async def hrs_prototype(self, **params: Any) -> Any:
|
|
90
|
+
"""Assemble the mechanical HRS prototype of one plan: paginated per-G sections plus MRS/HRS digests, gaps seed, and the consumer-model guidance contract."""
|
|
91
|
+
return await self._call("hrs_prototype", params)
|
|
92
|
+
|
|
93
|
+
async def step_xref(self, **params: Any) -> Any:
|
|
94
|
+
"""Cross-reference report over per-field content fingerprints: report where a signature/text fragment or a given step field is DEFINED versus where it is INLINED across the plan."""
|
|
95
|
+
return await self._call("step_xref", params)
|
|
96
|
+
|
|
97
|
+
async def graph_deps(self, **params: Any) -> Any:
|
|
98
|
+
"""Return the dependency neighborhood (depends_on and dependents) of one step."""
|
|
99
|
+
return await self._call("graph_deps", params)
|
|
100
|
+
|
|
101
|
+
async def graph_order(self, **params: Any) -> Any:
|
|
102
|
+
"""Return a paginated page of the topological execution order of a plan's steps."""
|
|
103
|
+
return await self._call("graph_order", params)
|
|
104
|
+
|
|
105
|
+
async def graph_parallel_map(self, **params: Any) -> Any:
|
|
106
|
+
"""Return a paginated page of the parallel wave partition of a plan's steps by prerequisite depth."""
|
|
107
|
+
return await self._call("graph_parallel_map", params)
|
|
108
|
+
|
|
109
|
+
async def graph_impact(self, **params: Any) -> Any:
|
|
110
|
+
"""Return the read-only transitive impact set of one step."""
|
|
111
|
+
return await self._call("graph_impact", params)
|
|
112
|
+
|
|
113
|
+
async def graph_dependents(self, **params: Any) -> Any:
|
|
114
|
+
"""Return a paginated page of the transitive dependency-edge closure of one step."""
|
|
115
|
+
return await self._call("graph_dependents", params)
|
|
116
|
+
|
|
117
|
+
async def execution_graph(self, **params: Any) -> Any:
|
|
118
|
+
"""Return a paginated page of the extended execution graph (explicit + inferred edges) of a plan's steps."""
|
|
119
|
+
return await self._call("execution_graph", params)
|
|
120
|
+
|
|
121
|
+
async def execution_dependency_suggest(self, **params: Any) -> Any:
|
|
122
|
+
"""Propose depends_on additions for a plan's unambiguous inferred execution-graph edges; read-only."""
|
|
123
|
+
return await self._call("execution_dependency_suggest", params)
|
|
124
|
+
|
|
125
|
+
async def execution_dependency_apply(self, **params: Any) -> Any:
|
|
126
|
+
"""Apply an execution_dependency_suggest proposal as depends_on additions, or dry-run it."""
|
|
127
|
+
return await self._call("execution_dependency_apply", params)
|
|
128
|
+
|
|
129
|
+
async def step_dependency_list(self, **params: Any) -> Any:
|
|
130
|
+
"""List one step's top-level depends_on edges and the sibling steps that depend on it."""
|
|
131
|
+
return await self._call("step_dependency_list", params)
|
|
132
|
+
|
|
133
|
+
async def step_dependency_add(self, **params: Any) -> Any:
|
|
134
|
+
"""Add one sibling dependency to a step's top-level depends_on; idempotent, cycle-safe, and admitted under the step mutation regime."""
|
|
135
|
+
return await self._call("step_dependency_add", params)
|
|
136
|
+
|
|
137
|
+
async def step_dependency_remove(self, **params: Any) -> Any:
|
|
138
|
+
"""Remove one dependency from a step's top-level depends_on; idempotent and admitted under the step mutation regime."""
|
|
139
|
+
return await self._call("step_dependency_remove", params)
|
|
140
|
+
|
|
141
|
+
async def step_dependency_set(self, **params: Any) -> Any:
|
|
142
|
+
"""Replace a step's entire top-level depends_on with a validated, deduplicated, cycle-safe sibling list."""
|
|
143
|
+
return await self._call("step_dependency_set", params)
|
|
144
|
+
|
|
145
|
+
async def step_dependency_clear(self, **params: Any) -> Any:
|
|
146
|
+
"""Clear all of a step's top-level depends_on edges, admitted under the step mutation regime."""
|
|
147
|
+
return await self._call("step_dependency_clear", params)
|
|
148
|
+
|
|
149
|
+
async def step_dependency_preview(self, **params: Any) -> Any:
|
|
150
|
+
"""Dry-run a batch of dependency changes and report validity, cycle risk, and the before/after execution order and parallel waves."""
|
|
151
|
+
return await self._call("step_dependency_preview", params)
|
|
152
|
+
|
|
153
|
+
async def step_dependency_apply(self, **params: Any) -> Any:
|
|
154
|
+
"""Apply a batch of dependency changes as one revision, or dry-run them; all-or-nothing, cycle-safe."""
|
|
155
|
+
return await self._call("step_dependency_apply", params)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = ["StepGraphCommandsMixin"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Client-side check of the identifier rule D1 (uuid4 discipline, block 2, D7; TODO 93bc1adc-755b-461c-8f1e-20e51b3c0b25).
|
|
2
|
+
|
|
3
|
+
The client is a separate wheel, so the rule is a copy of ``plan_manager.storage.id_rule``: an identifier is canonical
|
|
4
|
+
8-4-4-4-12 text, version 4 (RFC 4122 variant), not nil, and has no run of eight or more '0' hex digits (hyphens ignored).
|
|
5
|
+
``check_call_ids`` runs in the facade before every call: a value of a parameter named in ``UUID_PARAMETER_NAMES`` (the
|
|
6
|
+
server's ``format: uuid`` parameters) that fails the rule is refused locally with the server's own answer shape
|
|
7
|
+
(-32602, violation ``value``, the parameter as field, the exact value). The server stays the authority.
|
|
8
|
+
|
|
9
|
+
Free-text identifiers are not in the table and are not checked: they are not uuids of this project (request, session and
|
|
10
|
+
call ids, provider request ids, uuid7 evidence ids, idempotency keys, sha256 hashes, dialogue and transcript references,
|
|
11
|
+
the server id of a tool). ``plan`` takes a name or an id: only a value of the id shape is checked (``ID_OR_NAME_PARAMETERS``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import uuid
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
_ZERO_RUN = re.compile(r"0{8}")
|
|
21
|
+
_CANONICAL = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
|
22
|
+
EXPECTED = "a valid identifier: uuid4, not nil, without a run of eight zeros"
|
|
23
|
+
|
|
24
|
+
ID_OR_NAME_PARAMETERS: frozenset[str] = frozenset({"plan"})
|
|
25
|
+
|
|
26
|
+
UUID_PARAMETER_NAMES: frozenset[str] = frozenset({
|
|
27
|
+
"anchor_plan", "anchor_plan_uuid", "anchor_project_id", "anchor_ref_id", "anchor_revision_uuid", "assignment_uuid",
|
|
28
|
+
"attempt_id", "binding_uuid", "bug", "bug_fix", "bug_id", "calendar_entry", "comment", "comment_uuid", "dependency_uuid",
|
|
29
|
+
"duplicate_of_uuid", "entity_id", "entity_ids", "entity_uuid", "envelope_uuid", "escalation_uuid", "from_entity_uuid",
|
|
30
|
+
"from_todo", "identifiers", "impact_uuid", "link", "linked_plan_uuid", "membership_uuid", "model_uuid",
|
|
31
|
+
"new_anchor_plan_uuid", "new_anchor_project_id", "new_anchor_ref_id", "new_anchor_revision_uuid", "new_owner_id",
|
|
32
|
+
"new_source_plan_uuid", "new_source_project_id", "new_source_ref_id", "new_source_revision_uuid", "parent_bug_uuid",
|
|
33
|
+
"policy_uuid", "profile_uuid", "project", "propagation_id", "provider_uuid", "review_uuid", "revision", "role_uuid",
|
|
34
|
+
"source_plan_uuid", "source_project_id", "source_ref", "source_ref_id", "source_revision_uuid", "supersedes_comment_uuid",
|
|
35
|
+
"target_plan_uuid", "target_project_id", "target_ref_id", "target_revision_uuid", "to_entity_uuid", "to_todo", "todo",
|
|
36
|
+
"tool_uuid", "toolset_uuid", "wish",
|
|
37
|
+
})
|
|
38
|
+
"""The parameters the server declares ``format: uuid`` (every command of the live catalog, generated from the schemas)."""
|
|
39
|
+
|
|
40
|
+
FREE_TEXT_PARAMETERS: dict[str, frozenset[str]] = {
|
|
41
|
+
"plan_comment_set": frozenset({"comment"}),
|
|
42
|
+
"project_update": frozenset({"comment"}),
|
|
43
|
+
}
|
|
44
|
+
"""Per command: parameters whose NAME is in ``UUID_PARAMETER_NAMES`` for other commands but carry free text here (the
|
|
45
|
+
``comment`` of plan_comment_set and project_update is prose, the ``comment`` of comment_update/comment_delete is a uuid)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidIdentifierError(ValueError):
|
|
49
|
+
"""A client-side refusal of an identifier value: the server's -32602 / violation value shape, before the call."""
|
|
50
|
+
|
|
51
|
+
code = -32602
|
|
52
|
+
violation = "value"
|
|
53
|
+
|
|
54
|
+
def __init__(self, field: str, value: Any, defect: str) -> None:
|
|
55
|
+
self.field = field
|
|
56
|
+
self.value = value
|
|
57
|
+
super().__init__(
|
|
58
|
+
f"parameter {field!r} must be {EXPECTED}, got {value!r} ({defect})"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def as_answer(self) -> dict[str, Any]:
|
|
62
|
+
"""The server's -32602 ErrorResult for this refusal: the answer a command gives, not an exception."""
|
|
63
|
+
return {
|
|
64
|
+
"success": False,
|
|
65
|
+
"error": {
|
|
66
|
+
"code": self.code,
|
|
67
|
+
"message": str(self),
|
|
68
|
+
"data": {"field": self.field, "violation": self.violation, "expected": EXPECTED, "got": self.value},
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def id_defect(value: Any) -> str | None:
|
|
74
|
+
"""Why ``value`` is not a valid identifier, or None when it is one (the rule D1 of the server)."""
|
|
75
|
+
if isinstance(value, uuid.UUID):
|
|
76
|
+
candidate = value
|
|
77
|
+
else:
|
|
78
|
+
try:
|
|
79
|
+
candidate = uuid.UUID(str(value))
|
|
80
|
+
except (AttributeError, TypeError, ValueError):
|
|
81
|
+
return f"invalid uuid: {value!r}"
|
|
82
|
+
if candidate.int == 0:
|
|
83
|
+
return f"nil uuid: {candidate}"
|
|
84
|
+
if candidate.version != 4:
|
|
85
|
+
return f"not version 4: {candidate}"
|
|
86
|
+
if _ZERO_RUN.search(candidate.hex):
|
|
87
|
+
return f"a run of eight zeros: {candidate}"
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_id_shape(value: Any) -> bool:
|
|
92
|
+
return isinstance(value, str) and _CANONICAL.match(value) is not None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_call_ids(params: dict[str, Any] | None, command: str | None = None) -> None:
|
|
96
|
+
"""Refuse locally every identifier value of a call that fails rule D1 (see the module docstring).
|
|
97
|
+
|
|
98
|
+
Raises InvalidIdentifierError naming the parameter and the exact value. Nothing is sent when it raises. A parameter
|
|
99
|
+
that ``FREE_TEXT_PARAMETERS`` lists for ``command`` is free text there and is never checked.
|
|
100
|
+
"""
|
|
101
|
+
free_text = FREE_TEXT_PARAMETERS.get(command or "", frozenset())
|
|
102
|
+
for name, value in (params or {}).items():
|
|
103
|
+
if name in free_text:
|
|
104
|
+
continue
|
|
105
|
+
if name in UUID_PARAMETER_NAMES:
|
|
106
|
+
values = value if isinstance(value, list) else [value]
|
|
107
|
+
for item in values:
|
|
108
|
+
if isinstance(item, str):
|
|
109
|
+
defect = id_defect(item)
|
|
110
|
+
if defect is not None:
|
|
111
|
+
raise InvalidIdentifierError(name, item, defect)
|
|
112
|
+
elif name in ID_OR_NAME_PARAMETERS and _is_id_shape(value):
|
|
113
|
+
defect = id_defect(value)
|
|
114
|
+
if defect is not None:
|
|
115
|
+
raise InvalidIdentifierError(name, value, defect)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Walk the `info` command to the end of a node, page by page (R116b info norm, client helper).
|
|
2
|
+
|
|
3
|
+
`info` answers one page at a time: a page has ``has_more`` and ``next_offset``, and the content of
|
|
4
|
+
the addressed node is under the key of its section. ``walk_info`` follows ``next_offset`` until
|
|
5
|
+
``has_more`` is false and assembles the content. With ``follow_refs`` every oversize child that
|
|
6
|
+
came back as an ``info_ref`` stub is read the same way, so the result is the full section data.
|
|
7
|
+
Without a section the first page is read, and every section listed in its ``contents`` is walked.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
13
|
+
|
|
14
|
+
InfoCall = Callable[..., Awaitable[Any]]
|
|
15
|
+
|
|
16
|
+
_MAX_DEPTH = 16
|
|
17
|
+
_MAX_PAGES = 100_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_stub(value: Any) -> bool:
|
|
21
|
+
return isinstance(value, dict) and "info_ref" in value and isinstance(value["info_ref"], dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _append(kind: str, acc: Any, piece: Any) -> Any:
|
|
25
|
+
if kind == "object":
|
|
26
|
+
merged = {} if acc is None else acc
|
|
27
|
+
merged.update(piece)
|
|
28
|
+
return merged
|
|
29
|
+
if kind == "array":
|
|
30
|
+
items = [] if acc is None else acc
|
|
31
|
+
items.extend(piece)
|
|
32
|
+
return items
|
|
33
|
+
if kind == "string":
|
|
34
|
+
return ("" if acc is None else acc) + piece
|
|
35
|
+
return piece
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _read_node(info: InfoCall, params: Dict[str, Any]) -> Any:
|
|
39
|
+
"""Every page of one node, from offset 0 to next_offset null, assembled into its content."""
|
|
40
|
+
section = params["section"]
|
|
41
|
+
content: Any = None
|
|
42
|
+
offset = 0
|
|
43
|
+
pages = 0
|
|
44
|
+
while True:
|
|
45
|
+
page = await info(**params, offset=offset)
|
|
46
|
+
pages += 1
|
|
47
|
+
content = _append(page["kind"], content, page[section])
|
|
48
|
+
if not page["has_more"]:
|
|
49
|
+
return content
|
|
50
|
+
following = page["next_offset"]
|
|
51
|
+
if not isinstance(following, int) or following <= offset or pages >= _MAX_PAGES:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"info paging does not advance: offset %d, next_offset %r, page %d" % (offset, following, pages)
|
|
54
|
+
)
|
|
55
|
+
offset = following
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _resolve(info: InfoCall, value: Any, depth: int) -> Any:
|
|
59
|
+
"""Replace every info_ref stub inside a walked value by the content it points to."""
|
|
60
|
+
if depth > _MAX_DEPTH:
|
|
61
|
+
raise ValueError("info stubs nest deeper than %d levels" % _MAX_DEPTH)
|
|
62
|
+
if _is_stub(value):
|
|
63
|
+
ref = value["info_ref"]
|
|
64
|
+
child = {key: ref[key] for key in ("section", "subsection", "path") if key in ref}
|
|
65
|
+
return await walk_info(info, follow_refs=True, _depth=depth + 1, **child)
|
|
66
|
+
if isinstance(value, dict):
|
|
67
|
+
return {key: await _resolve(info, item, depth) for key, item in value.items()}
|
|
68
|
+
if isinstance(value, list):
|
|
69
|
+
return [await _resolve(info, item, depth) for item in value]
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def walk_info(
|
|
74
|
+
info: InfoCall,
|
|
75
|
+
*,
|
|
76
|
+
section: Optional[str] = None,
|
|
77
|
+
subsection: Optional[str] = None,
|
|
78
|
+
path: Optional[str] = None,
|
|
79
|
+
follow_refs: bool = False,
|
|
80
|
+
_depth: int = 0,
|
|
81
|
+
) -> Any:
|
|
82
|
+
"""The assembled content of one `info` node, or of every section when ``section`` is None.
|
|
83
|
+
|
|
84
|
+
``info`` is the async `info` call (the client method or any callable with the same parameters
|
|
85
|
+
that returns the unwrapped data of the answer). Pages are requested in stored order, each once.
|
|
86
|
+
"""
|
|
87
|
+
if section is None:
|
|
88
|
+
first = await info()
|
|
89
|
+
walked = {}
|
|
90
|
+
for entry in first["contents"]:
|
|
91
|
+
name = entry["ref"]["section"]
|
|
92
|
+
walked[name] = await walk_info(info, section=name, follow_refs=follow_refs, _depth=_depth)
|
|
93
|
+
return walked
|
|
94
|
+
params: Dict[str, Any] = {"section": section}
|
|
95
|
+
if subsection is not None:
|
|
96
|
+
params["subsection"] = subsection
|
|
97
|
+
if path is not None:
|
|
98
|
+
params["path"] = path
|
|
99
|
+
content = await _read_node(info, params)
|
|
100
|
+
if follow_refs:
|
|
101
|
+
content = await _resolve(info, content, _depth)
|
|
102
|
+
return content
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _page_of_envelope(raw: Any) -> Any:
|
|
106
|
+
"""The bare info page of a client reply.
|
|
107
|
+
|
|
108
|
+
The client `info` method answers the command envelope {command, job_id, status, result: {success,
|
|
109
|
+
data: page}} (bug 05336ae7). The walker reads the page itself (kind, has_more, next_offset and the
|
|
110
|
+
section key), so the envelope is unwrapped here; a reply that is already a bare page passes through.
|
|
111
|
+
A failed envelope raises, never a silent empty page.
|
|
112
|
+
"""
|
|
113
|
+
if isinstance(raw, dict) and isinstance(raw.get("result"), dict):
|
|
114
|
+
result = raw["result"]
|
|
115
|
+
if result.get("success") is False:
|
|
116
|
+
raise RuntimeError("info failed: %r" % (result,))
|
|
117
|
+
if "data" in result:
|
|
118
|
+
return result["data"]
|
|
119
|
+
return raw
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def info_walk(client: Any, **params: Any) -> Any:
|
|
123
|
+
"""Read one `info` node, or every section, to its end through a client's ``info`` method.
|
|
124
|
+
|
|
125
|
+
``client`` is any object with an async ``info(**params)`` method (the PlanManagerClient). This is a
|
|
126
|
+
module function, not a facade method: the facade must expose only canonical command names.
|
|
127
|
+
Parameters are those of :func:`walk_info`: section, subsection, path and follow_refs.
|
|
128
|
+
The client reply is unwrapped to the bare page first (see :func:`_page_of_envelope`).
|
|
129
|
+
"""
|
|
130
|
+
async def _info(**page_params: Any) -> Any:
|
|
131
|
+
return _page_of_envelope(await client.info(**page_params))
|
|
132
|
+
|
|
133
|
+
return await walk_info(_info, **params)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Scenario helpers for installed public-client pipeline checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from plan_manager_client.config import ClientConnectionConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PREFIX = "live-smoke-"
|
|
13
|
+
DEFAULT_PROJECT_ID = "f06b7269-cc9c-4293-886b-24984e4033ba"
|
|
14
|
+
|
|
15
|
+
_CALL_TIMEOUT: float | None = None
|
|
16
|
+
_CALL_WATCHDOG_GRACE_SECONDS = 15.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def configure_call_watchdog(timeout: float | None, *, grace_seconds: float = _CALL_WATCHDOG_GRACE_SECONDS) -> None:
|
|
20
|
+
"""Configure the maximum public-client method duration for scenario calls."""
|
|
21
|
+
global _CALL_TIMEOUT
|
|
22
|
+
_CALL_TIMEOUT = None if timeout is None else float(timeout) + float(grace_seconds)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_config(args: argparse.Namespace) -> ClientConnectionConfig:
|
|
26
|
+
"""Build client configuration from a pipeline scenario argparse namespace."""
|
|
27
|
+
return ClientConnectionConfig.from_sources(
|
|
28
|
+
base_url=getattr(args, "base_url", None),
|
|
29
|
+
protocol=getattr(args, "protocol", None),
|
|
30
|
+
host=getattr(args, "host", None),
|
|
31
|
+
port=getattr(args, "port", None),
|
|
32
|
+
timeout=getattr(args, "timeout", None),
|
|
33
|
+
env_prefix="PLAN_MANAGER_",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _unwrap(raw: Any) -> tuple[bool, Any]:
|
|
38
|
+
current = raw
|
|
39
|
+
for _ in range(12):
|
|
40
|
+
if isinstance(current, dict):
|
|
41
|
+
if "status" in current and "result" in current:
|
|
42
|
+
if current.get("status") != "completed":
|
|
43
|
+
return False, current
|
|
44
|
+
current = current.get("result")
|
|
45
|
+
continue
|
|
46
|
+
if "success" in current and isinstance(current.get("success"), bool):
|
|
47
|
+
if not current["success"]:
|
|
48
|
+
return False, current.get("error", current)
|
|
49
|
+
return True, current.get("data", current)
|
|
50
|
+
return True, current
|
|
51
|
+
return False, {"message": "response envelope nesting exceeded", "raw": raw}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def call(client: Any, name: str, params: dict[str, Any] | None = None) -> tuple[bool, Any]:
|
|
55
|
+
"""Call one public client method and normalize success/error envelopes."""
|
|
56
|
+
try:
|
|
57
|
+
method = getattr(client, name)
|
|
58
|
+
coroutine = method(**(params or {}))
|
|
59
|
+
raw = await asyncio.wait_for(coroutine, timeout=_CALL_TIMEOUT) if _CALL_TIMEOUT else await coroutine
|
|
60
|
+
except Exception as exc: # noqa: BLE001 - scenario boundary.
|
|
61
|
+
return False, {"exception_type": type(exc).__name__, "message": str(exc)}
|
|
62
|
+
return _unwrap(raw)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["DEFAULT_PROJECT_ID", "PREFIX", "build_config", "call", "configure_call_watchdog"]
|