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.
Files changed (37) hide show
  1. plan_manager_client/__init__.py +10 -0
  2. plan_manager_client/_version.py +58 -0
  3. plan_manager_client/authoring_diagnostics.py +137 -0
  4. plan_manager_client/client.py +75 -0
  5. plan_manager_client/config.py +320 -0
  6. plan_manager_client/delivery/__init__.py +7 -0
  7. plan_manager_client/delivery/archive.py +140 -0
  8. plan_manager_client/delivery/atomicity.py +93 -0
  9. plan_manager_client/delivery/commit.py +113 -0
  10. plan_manager_client/delivery/destination.py +140 -0
  11. plan_manager_client/delivery/provenance.py +88 -0
  12. plan_manager_client/delivery/upload.py +118 -0
  13. plan_manager_client/dispatch.py +380 -0
  14. plan_manager_client/export_fetch_chunks.py +166 -0
  15. plan_manager_client/export_fetch_request.py +88 -0
  16. plan_manager_client/export_fetch_unpack.py +149 -0
  17. plan_manager_client/export_fetch_verify.py +101 -0
  18. plan_manager_client/facade_agent_config_mixin.py +224 -0
  19. plan_manager_client/facade_bug_project_mixin.py +202 -0
  20. plan_manager_client/facade_context_gate_mixin.py +303 -0
  21. plan_manager_client/facade_entity_common_mixin.py +38 -0
  22. plan_manager_client/facade_hrs_file_mixin.py +206 -0
  23. plan_manager_client/facade_plan_hrs_mixin.py +200 -0
  24. plan_manager_client/facade_runtime_mixin/__init__.py +11 -0
  25. plan_manager_client/facade_runtime_mixin/authoring_part.py +442 -0
  26. plan_manager_client/facade_runtime_mixin/facade.py +380 -0
  27. plan_manager_client/facade_runtime_mixin/overlay_part.py +192 -0
  28. plan_manager_client/facade_step_graph_mixin.py +158 -0
  29. plan_manager_client/id_rule.py +115 -0
  30. plan_manager_client/info_walker.py +133 -0
  31. plan_manager_client/scenario_runtime.py +65 -0
  32. plan_manager_client/server_api.py +427 -0
  33. plan_manager_client/server_api_mutating.py +105 -0
  34. plan_manager_client_dev-0.2.186.dist-info/METADATA +10 -0
  35. plan_manager_client_dev-0.2.186.dist-info/RECORD +37 -0
  36. plan_manager_client_dev-0.2.186.dist-info/WHEEL +5 -0
  37. plan_manager_client_dev-0.2.186.dist-info/top_level.txt +1 -0
@@ -0,0 +1,10 @@
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
+ from plan_manager_client._version import __version__
8
+ from plan_manager_client.info_walker import info_walk, walk_info
9
+
10
+ __all__ = ["__version__", "info_walk", "walk_info"]
@@ -0,0 +1,58 @@
1
+ """Resolve the client package version from the single release source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version as installed_version
6
+ from pathlib import Path
7
+ import configparser
8
+ import tomllib
9
+
10
+
11
+ _DIST_NAME = "plan-manager-client"
12
+ # The DEV build (build.sh --dev yes) publishes the same import package under the distribution name plan-manager-client-dev.
13
+ _DIST_NAMES = (_DIST_NAME, "plan-manager-client-dev")
14
+
15
+
16
+ def version_from_source_tree(module_file: Path | None = None) -> str:
17
+ """Read the version from the repository root or, in an sdist, its frozen metadata."""
18
+ here = (module_file or Path(__file__)).resolve()
19
+ source_roots = (
20
+ here.parents[2] / "pyproject.toml",
21
+ here.parents[1] / "PKG-INFO",
22
+ here.parents[1] / "setup.cfg",
23
+ )
24
+ for candidate in source_roots:
25
+ if not candidate.is_file():
26
+ continue
27
+ if candidate.name == "pyproject.toml":
28
+ data = tomllib.loads(candidate.read_text(encoding="utf-8"))
29
+ return data["project"]["version"]
30
+ if candidate.name == "PKG-INFO":
31
+ for line in candidate.read_text(encoding="utf-8").splitlines():
32
+ if line.startswith("Version: "):
33
+ return line.removeprefix("Version: ").strip()
34
+ continue
35
+ if candidate.name == "setup.cfg":
36
+ parser = configparser.ConfigParser()
37
+ parser.read(candidate, encoding="utf-8")
38
+ if parser.has_option("metadata", "version"):
39
+ return parser.get("metadata", "version").strip()
40
+ raise RuntimeError("could not resolve plan-manager-client version from source metadata")
41
+
42
+
43
+ def resolve_version() -> str:
44
+ """Prefer source metadata for an editable checkout, otherwise use the installed version."""
45
+ if (Path(__file__).resolve().parents[2] / "pyproject.toml").is_file():
46
+ return version_from_source_tree()
47
+ for name in _DIST_NAMES:
48
+ try:
49
+ return installed_version(name)
50
+ except PackageNotFoundError:
51
+ continue
52
+ return version_from_source_tree()
53
+
54
+
55
+ __version__ = resolve_version()
56
+
57
+
58
+ __all__ = ["__version__", "resolve_version", "version_from_source_tree"]
@@ -0,0 +1,137 @@
1
+ """Opt-in safe diagnostics for the 14-command authoring client surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ import json
7
+ import logging
8
+ from typing import Any
9
+ from uuid import uuid4
10
+
11
+
12
+ AUTHORING_DIAGNOSTIC_COMMANDS = frozenset(
13
+ {
14
+ "authoring_session_create",
15
+ "authoring_session_get",
16
+ "authoring_session_list",
17
+ "authoring_session_update",
18
+ "authoring_session_delete",
19
+ "authoring_job_start",
20
+ "authoring_job_status",
21
+ "authoring_job_resume",
22
+ "authoring_job_stop",
23
+ "authoring_job_delete",
24
+ "authoring_job_get",
25
+ "authoring_job_update",
26
+ "authoring_child_list",
27
+ "authoring_context_read",
28
+ "authoring_child_write",
29
+ "authoring_child_start",
30
+ "authoring_child_status",
31
+ "authoring_child_wait",
32
+ "authoring_child_answer",
33
+ "authoring_step_submit",
34
+ "authoring_escalate",
35
+ "plan_prompt_create",
36
+ "plan_prompt_get",
37
+ "plan_prompt_list",
38
+ "plan_prompt_update",
39
+ "plan_prompt_delete",
40
+ "plan_model_mapping_set",
41
+ "plan_model_mapping_get",
42
+ "authoring_usage_get",
43
+ }
44
+ )
45
+
46
+ _LOGGER = logging.getLogger("plan_manager_client.authoring.diagnostics")
47
+
48
+
49
+ def new_correlation_id() -> str:
50
+ """Create one opaque client-side correlation ID per public dispatch."""
51
+ return str(uuid4())
52
+
53
+
54
+ def emit(
55
+ event: str,
56
+ *,
57
+ command: str,
58
+ correlation_id: str,
59
+ parameters: object | None = None,
60
+ result: object | None = None,
61
+ error: object | None = None,
62
+ ) -> None:
63
+ """Emit one JSON record using only configured logging handlers and levels."""
64
+ if not _LOGGER.isEnabledFor(logging.INFO):
65
+ return
66
+ record: dict[str, object] = {
67
+ "event": event,
68
+ "command": command,
69
+ "correlation_id": correlation_id,
70
+ }
71
+ if parameters is not None:
72
+ record["parameter_types"] = _parameter_type_map(parameters)
73
+ if result is not None:
74
+ record["result_keys"] = _result_key_names(result)
75
+ if error is not None:
76
+ record["error"] = _error_summary(error)
77
+ _LOGGER.info("authoring_diagnostic=%s", json.dumps(record, sort_keys=True, separators=(",", ":")))
78
+
79
+
80
+ def _parameter_type_map(parameters: object) -> dict[str, str]:
81
+ if not isinstance(parameters, Mapping):
82
+ return {}
83
+ return {
84
+ str(name): _value_type(value)
85
+ for name, value in sorted(parameters.items(), key=lambda item: str(item[0]))
86
+ }
87
+
88
+
89
+ def _result_key_names(result: object) -> list[str]:
90
+ if not isinstance(result, Mapping):
91
+ return []
92
+ data = result.get("data")
93
+ if isinstance(data, Mapping):
94
+ return sorted(str(key) for key in data)
95
+ return sorted(str(key) for key in result if key not in {"error", "message"})
96
+
97
+
98
+ def _error_summary(error: object) -> dict[str, object]:
99
+ payload = error
100
+ if isinstance(payload, Mapping) and isinstance(payload.get("error"), Mapping):
101
+ payload = payload["error"]
102
+ if isinstance(payload, Mapping):
103
+ code = payload.get("code")
104
+ data = payload.get("data", payload.get("details"))
105
+ else:
106
+ code = getattr(payload, "code", None)
107
+ data = getattr(payload, "data", getattr(payload, "details", None))
108
+ summary: dict[str, object] = {"code": code}
109
+ if isinstance(data, Mapping):
110
+ safe_data: dict[str, object] = {}
111
+ for key in ("field", "violation", "expected"):
112
+ value = data.get(key)
113
+ if isinstance(value, str):
114
+ safe_data[key] = value[:200]
115
+ if "got" in data:
116
+ safe_data["got_type"] = _value_type(data["got"])
117
+ elif isinstance(data.get("got_type"), str):
118
+ safe_data["got_type"] = data["got_type"][:80]
119
+ if safe_data:
120
+ summary["data"] = safe_data
121
+ return summary
122
+
123
+
124
+ def _value_type(value: object) -> str:
125
+ if value is None:
126
+ return "null"
127
+ if isinstance(value, bool):
128
+ return "boolean"
129
+ if isinstance(value, str):
130
+ return "string"
131
+ if isinstance(value, (int, float)):
132
+ return "number"
133
+ if isinstance(value, Mapping):
134
+ return "object"
135
+ if isinstance(value, (list, tuple)):
136
+ return "array"
137
+ return type(value).__name__
@@ -0,0 +1,75 @@
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_agent_config_mixin import AgentConfigCommandsMixin
15
+ from plan_manager_client.facade_bug_project_mixin import BugProjectCommandsMixin
16
+ from plan_manager_client.facade_context_gate_mixin import ContextGateCommandsMixin
17
+ from plan_manager_client.facade_entity_common_mixin import EntityCommonCommandsMixin
18
+ from plan_manager_client.facade_hrs_file_mixin import HrsFileCommandsMixin
19
+ from plan_manager_client.facade_plan_hrs_mixin import PlanHrsCommandsMixin
20
+ from plan_manager_client.facade_runtime_mixin import RuntimeCommandsMixin
21
+ from plan_manager_client.facade_step_graph_mixin import StepGraphCommandsMixin
22
+
23
+
24
+ class PlanManagerClient(
25
+ _CommandDispatchMixin,
26
+ PlanHrsCommandsMixin,
27
+ HrsFileCommandsMixin,
28
+ StepGraphCommandsMixin,
29
+ ContextGateCommandsMixin,
30
+ EntityCommonCommandsMixin,
31
+ RuntimeCommandsMixin,
32
+ BugProjectCommandsMixin,
33
+ AgentConfigCommandsMixin,
34
+ ):
35
+ """Full command-surface facade for the plan_manager JSON-RPC API.
36
+
37
+ Composes (HOLDS, never inherits) a
38
+ mcp_proxy_adapter.client.jsonrpc_client.client.JsonRpcClient on the private
39
+ attribute ``self._rpc``, which supplies transport (http/https/mTLS), token
40
+ authentication, queued-job polling, and chunked file transfer with sha256
41
+ verification and resume. Because the transport client is held rather than
42
+ inherited, the network interaction is fully hidden: the public surface of
43
+ this class is exactly one public async method per plan_manager command,
44
+ contributed by the internal _call dispatch coroutine (queued-command
45
+ auto-polling per queue_get_job_status semantics) from _CommandDispatchMixin
46
+ and the seven command-family mixins, together covering every name in
47
+ plan_manager_client.server_api.COMMAND_NAMES. Construct directly with the
48
+ JsonRpcClient constructor arguments, or via
49
+ PlanManagerClient(**config.to_jsonrpc_kwargs()) using a
50
+ plan_manager_client.config.ClientConnectionConfig instance. Connections are
51
+ direct to the plan_manager server; this class performs no proxy call_server
52
+ routing.
53
+ """
54
+
55
+ def __init__(self, **jsonrpc_kwargs: Any) -> None:
56
+ """Compose the underlying JsonRpcClient from JsonRpcClient constructor kwargs.
57
+
58
+ Accepts exactly the keyword arguments of
59
+ mcp_proxy_adapter.client.jsonrpc_client.client.JsonRpcClient (protocol,
60
+ host, port, token_header, token, cert, key, ca, check_hostname,
61
+ timeout), so PlanManagerClient(**config.to_jsonrpc_kwargs()) from a
62
+ plan_manager_client.config.ClientConnectionConfig works directly. The
63
+ constructed client is stored on the private attribute self._rpc and is
64
+ never exposed as a public method, keeping the public surface exactly the
65
+ canonical command methods.
66
+ """
67
+ self._rpc = JsonRpcClient(**jsonrpc_kwargs)
68
+ # Chain the cooperative initialisers of every mixin in the MRO (for example
69
+ # RuntimeCommandsMixin, which creates runtimeOverlayPart and
70
+ # runtimeAuthoringPart). Called without arguments: the mixins take none, and
71
+ # object.__init__ rejects keyword arguments.
72
+ super().__init__()
73
+
74
+
75
+ __all__ = ["PlanManagerClient"]
@@ -0,0 +1,320 @@
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
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from typing import Optional
14
+ from urllib.parse import urlsplit
15
+
16
+
17
+ ENDPOINT_ENV_NAMES = {
18
+ "base_url": "PLAN_MANAGER_BASE_URL",
19
+ "protocol": "PLAN_MANAGER_PROTOCOL",
20
+ "host": "PLAN_MANAGER_HOST",
21
+ "port": "PLAN_MANAGER_PORT",
22
+ }
23
+
24
+
25
+ class MissingClientEndpointError(ValueError):
26
+ """Raised when no complete client endpoint is provided by CLI, env, or .env."""
27
+
28
+
29
+ def _read_dotenv(path: str | Path | None) -> dict[str, str]:
30
+ """Read simple KEY=VALUE entries from a dotenv file without side effects."""
31
+ if path is None:
32
+ return {}
33
+ dotenv_path = Path(path)
34
+ if not dotenv_path.is_file():
35
+ return {}
36
+ values: dict[str, str] = {}
37
+ for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines():
38
+ line = raw_line.strip()
39
+ if not line or line.startswith("#") or "=" not in line:
40
+ continue
41
+ key, value = line.split("=", 1)
42
+ values[key.strip()] = value.strip().strip("'\"")
43
+ return values
44
+
45
+
46
+ def _split_base_url(base_url: str) -> tuple[str, str, int]:
47
+ parts = urlsplit(base_url)
48
+ protocol = parts.scheme
49
+ host = parts.hostname
50
+ if not protocol or not host:
51
+ raise MissingClientEndpointError(
52
+ f"invalid PLAN_MANAGER_BASE_URL/base_url {base_url!r}: protocol and host are required"
53
+ )
54
+ port = parts.port
55
+ if port is None:
56
+ if protocol == "https":
57
+ port = 443
58
+ elif protocol == "http":
59
+ port = 80
60
+ else:
61
+ raise MissingClientEndpointError(
62
+ f"invalid PLAN_MANAGER_BASE_URL/base_url {base_url!r}: explicit port is required for protocol {protocol!r}"
63
+ )
64
+ return protocol, host, port
65
+
66
+
67
+ def resolve_endpoint_sources(
68
+ *,
69
+ base_url: str | None = None,
70
+ protocol: str | None = None,
71
+ host: str | None = None,
72
+ port: int | str | None = None,
73
+ dotenv_path: str | Path | None = ".env",
74
+ ) -> tuple[str, str, int]:
75
+ """Resolve endpoint as CLI values, then environment, then dotenv.
76
+
77
+ No default endpoint is invented. A caller that supplies neither a complete
78
+ URL nor a complete protocol/host/port triple gets a precise configuration
79
+ error instead of silently targeting localhost or a fleet host.
80
+ """
81
+ dotenv = _read_dotenv(dotenv_path)
82
+
83
+ def _source(name: str, explicit: Any) -> Any:
84
+ if explicit not in (None, ""):
85
+ return explicit
86
+ env_name = ENDPOINT_ENV_NAMES[name]
87
+ env_value = os.environ.get(env_name)
88
+ if env_value not in (None, ""):
89
+ return env_value
90
+ return dotenv.get(env_name)
91
+
92
+ if base_url not in (None, ""):
93
+ return _split_base_url(str(base_url))
94
+
95
+ explicit_triple = any(value not in (None, "") for value in (protocol, host, port))
96
+ if explicit_triple:
97
+ dotenv = _read_dotenv(dotenv_path)
98
+
99
+ def _non_base_source(name: str, explicit: Any) -> Any:
100
+ if explicit not in (None, ""):
101
+ return explicit
102
+ env_name = ENDPOINT_ENV_NAMES[name]
103
+ env_value = os.environ.get(env_name)
104
+ if env_value not in (None, ""):
105
+ return env_value
106
+ return dotenv.get(env_name)
107
+
108
+ effective_protocol = _non_base_source("protocol", protocol)
109
+ effective_host = _non_base_source("host", host)
110
+ effective_port = _non_base_source("port", port)
111
+ else:
112
+ effective_base_url = _source("base_url", None)
113
+ if effective_base_url:
114
+ return _split_base_url(str(effective_base_url))
115
+ effective_protocol = _source("protocol", protocol)
116
+ effective_host = _source("host", host)
117
+ effective_port = _source("port", port)
118
+ missing = [
119
+ name for name, value in (
120
+ ("protocol", effective_protocol),
121
+ ("host", effective_host),
122
+ ("port", effective_port),
123
+ )
124
+ if value in (None, "")
125
+ ]
126
+ if missing:
127
+ raise MissingClientEndpointError(
128
+ "missing Plan Manager endpoint configuration: provide --base-url or "
129
+ "--protocol/--host/--port, or set PLAN_MANAGER_BASE_URL, or set "
130
+ f"{', '.join(ENDPOINT_ENV_NAMES[name] for name in missing)} in the environment/.env"
131
+ )
132
+ try:
133
+ parsed_port = int(effective_port)
134
+ except (TypeError, ValueError) as exc:
135
+ raise MissingClientEndpointError(f"invalid PLAN_MANAGER_PORT/port {effective_port!r}") from exc
136
+ return str(effective_protocol), str(effective_host), parsed_port
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class ClientConnectionConfig:
141
+ """Direct-connection parameters for one JSON-RPC server.
142
+
143
+ Mirrors, field for field, the constructor of
144
+ mcp_proxy_adapter.client.jsonrpc_client.client.JsonRpcClient:
145
+
146
+ def __init__(
147
+ self,
148
+ protocol: str = "http",
149
+ host: str = "127.0.0.1",
150
+ port: int = 8080,
151
+ token_header: Optional[str] = None,
152
+ token: Optional[str] = None,
153
+ cert: Optional[str] = None,
154
+ key: Optional[str] = None,
155
+ ca: Optional[str] = None,
156
+ check_hostname: bool = False,
157
+ timeout: Optional[float] = None,
158
+ ) -> None: ...
159
+
160
+ Instances are immutable. Use to_jsonrpc_kwargs() to build the keyword
161
+ arguments for JsonRpcClient(**kwargs) or for any other client built on the
162
+ same constructor shape (for example a code-analysis-client construction
163
+ helper), so one configuration entity serves either server.
164
+ """
165
+
166
+ protocol: str = "http"
167
+ host: str = "127.0.0.1"
168
+ port: int = 8080
169
+ token_header: Optional[str] = None
170
+ token: Optional[str] = None
171
+ cert: Optional[str] = None
172
+ key: Optional[str] = None
173
+ ca: Optional[str] = None
174
+ check_hostname: bool = False
175
+ timeout: Optional[float] = None
176
+
177
+ @classmethod
178
+ def from_sources(
179
+ cls,
180
+ *,
181
+ base_url: str | None = None,
182
+ protocol: str | None = None,
183
+ host: str | None = None,
184
+ port: int | str | None = None,
185
+ timeout: Optional[float] = None,
186
+ env_prefix: str = "PLANMGR_CLIENT_",
187
+ dotenv_path: str | Path | None = ".env",
188
+ ) -> "ClientConnectionConfig":
189
+ """Resolve endpoint config with CLI > environment > .env precedence."""
190
+ dotenv = _read_dotenv(dotenv_path)
191
+
192
+ def source(name: str, cli_value: Any = None) -> Any:
193
+ if cli_value not in (None, ""):
194
+ return cli_value
195
+ env_name = f"{env_prefix}{name}"
196
+ env_value = os.environ.get(env_name)
197
+ if env_value not in (None, ""):
198
+ return env_value
199
+ value = dotenv.get(env_name)
200
+ if value not in (None, ""):
201
+ return value
202
+ return None
203
+
204
+ resolved_base_url = source("BASE_URL", base_url)
205
+ resolved_timeout = timeout
206
+ if resolved_timeout is None and source("TIMEOUT") is not None:
207
+ resolved_timeout = float(source("TIMEOUT"))
208
+ if resolved_base_url:
209
+ return cls.from_base_url(
210
+ str(resolved_base_url),
211
+ timeout=resolved_timeout,
212
+ env_prefix=env_prefix,
213
+ dotenv_path=dotenv_path,
214
+ )
215
+ resolved_protocol = source("PROTOCOL", protocol)
216
+ resolved_host = source("HOST", host)
217
+ resolved_port = source("PORT", port)
218
+ missing = [
219
+ name
220
+ for name, value in (("protocol", resolved_protocol), ("host", resolved_host), ("port", resolved_port))
221
+ if value in (None, "")
222
+ ]
223
+ if missing:
224
+ joined = ", ".join(missing)
225
+ raise MissingClientEndpointError(f"missing required client endpoint value(s): {joined}")
226
+ cert = source("CERT")
227
+ key = source("KEY")
228
+ actual_protocol = str(resolved_protocol)
229
+ if actual_protocol == "https" and cert and key:
230
+ actual_protocol = "mtls"
231
+ return cls(
232
+ protocol=actual_protocol,
233
+ host=str(resolved_host),
234
+ port=int(resolved_port),
235
+ token_header=source("TOKEN_HEADER"),
236
+ token=source("TOKEN"),
237
+ cert=cert,
238
+ key=key,
239
+ ca=source("CA"),
240
+ check_hostname=False,
241
+ timeout=resolved_timeout,
242
+ )
243
+
244
+ @classmethod
245
+ def from_base_url(
246
+ cls,
247
+ base_url: str,
248
+ *,
249
+ timeout: Optional[float] = None,
250
+ env_prefix: str = "PLANMGR_CLIENT_",
251
+ dotenv_path: str | Path | None = ".env",
252
+ ) -> "ClientConnectionConfig":
253
+ """Build direct-connection configuration from a server base URL.
254
+
255
+ URL parsing and TLS/token environment handling live in the public
256
+ client package so pipeline callers do not implement transport logic.
257
+ """
258
+ dotenv = _read_dotenv(dotenv_path)
259
+
260
+ def optional(name: str) -> str | None:
261
+ env_name = f"{env_prefix}{name}"
262
+ value = os.environ.get(env_name) or dotenv.get(env_name)
263
+ if value:
264
+ return value
265
+ return None
266
+
267
+ parsed = urlsplit(base_url)
268
+ protocol = parsed.scheme or optional("PROTOCOL")
269
+ host = parsed.hostname or optional("HOST")
270
+ port = parsed.port or optional("PORT")
271
+ missing = [
272
+ name
273
+ for name, value in (("protocol", protocol), ("host", host), ("port", port))
274
+ if value in (None, "")
275
+ ]
276
+ if missing:
277
+ joined = ", ".join(missing)
278
+ raise MissingClientEndpointError(f"missing required client endpoint value(s): {joined}")
279
+ cert = optional("CERT")
280
+ key = optional("KEY")
281
+ if protocol == "https" and cert and key:
282
+ protocol = "mtls"
283
+ timeout_value = timeout
284
+ timeout_from_sources = optional("TIMEOUT")
285
+ if timeout_value is None and timeout_from_sources:
286
+ timeout_value = float(timeout_from_sources)
287
+ return cls(
288
+ protocol=str(protocol),
289
+ host=str(host),
290
+ port=int(port),
291
+ token_header=optional("TOKEN_HEADER"),
292
+ token=optional("TOKEN"),
293
+ cert=cert,
294
+ key=key,
295
+ ca=optional("CA"),
296
+ check_hostname=False,
297
+ timeout=timeout_value,
298
+ )
299
+
300
+ def to_jsonrpc_kwargs(self) -> dict[str, Any]:
301
+ """Return constructor kwargs for JsonRpcClient(**kwargs)."""
302
+ return {
303
+ "protocol": self.protocol,
304
+ "host": self.host,
305
+ "port": self.port,
306
+ "token_header": self.token_header,
307
+ "token": self.token,
308
+ "cert": self.cert,
309
+ "key": self.key,
310
+ "ca": self.ca,
311
+ "check_hostname": self.check_hostname,
312
+ "timeout": self.timeout,
313
+ }
314
+
315
+
316
+ __all__ = [
317
+ "ClientConnectionConfig",
318
+ "MissingClientEndpointError",
319
+ "resolve_endpoint_sources",
320
+ ]
@@ -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
+ """