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,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
|
+
"""
|