chp-server 0.60.2__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.
chp_server/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """chp-server — independently installable reference CHP server.
2
+
3
+ Assembly only: protocol semantics live in chp-core; domain behavior attaches
4
+ through the `chp_server.ports` entry-point group. The only mandatory CHP
5
+ dependency is chp-core (DEC-SRV-001) — importing this package must never pull
6
+ an optional CHP package (PKG-009; enforced by tests/test_import_purity.py).
7
+ """
8
+
9
+ from .config import ServerConfig
10
+ from .features import FEATURES, FeatureDescriptor, FeatureRegistry
11
+ from .introduction import (
12
+ EntryPointIntroductionPort,
13
+ IntroductionCoordinator,
14
+ RemoteChpIntroductionSource,
15
+ SourceTrustPolicy,
16
+ )
17
+ from .local import ExistingHostPort, LocalArtifactPort, LocalStandalonePorts
18
+ from .resolver import DirectoryResolutionPort, advertisement_batch
19
+ from .ports import ENTRY_POINT_GROUP, PORT_ROLES, Attachment, AttachmentRegistry
20
+ from .profiles import CONFORMANCE_MANIFEST, PROFILES, validate_profile
21
+ from .server import Server, ServerInstanceIdentity, ServerStatus
22
+ from .app import CapabilityServer
23
+
24
+ # Replay / ReplayEvent (the typed /replay view) live in chp_server.replay, not the
25
+ # top-level namespace — API-008 keeps the public surface small and curated. Advanced
26
+ # callers use `from chp_server.replay import Replay`; the CLI imports it directly.
27
+
28
+ __all__ = [
29
+ "CapabilityServer",
30
+ "Attachment",
31
+ "AttachmentRegistry",
32
+ "CONFORMANCE_MANIFEST",
33
+ "ENTRY_POINT_GROUP",
34
+ "EntryPointIntroductionPort",
35
+ "ExistingHostPort",
36
+ "IntroductionCoordinator",
37
+ "SourceTrustPolicy",
38
+ "DirectoryResolutionPort",
39
+ "advertisement_batch",
40
+ "LocalArtifactPort",
41
+ "FEATURES",
42
+ "FeatureDescriptor",
43
+ "FeatureRegistry",
44
+ "LocalStandalonePorts",
45
+ "PORT_ROLES",
46
+ "PROFILES",
47
+ "RemoteChpIntroductionSource",
48
+ "Server",
49
+ "ServerConfig",
50
+ "ServerInstanceIdentity",
51
+ "ServerStatus",
52
+ "validate_profile",
53
+ ]
chp_server/_catalog.py ADDED
@@ -0,0 +1,60 @@
1
+ """Discover installed ``chp-adapter-*`` capability sets — the composable catalog.
2
+
3
+ Enumerates adapters registered under the ``chp.adapters`` entry-point group and reads each
4
+ one's ``@capability`` descriptors WITHOUT instantiating the adapter — so adapters that need
5
+ configuration (API keys, backends) still list. A live catalog that can't rot: it reflects what
6
+ is actually installed in this environment. Powers ``chp-server adapters``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+
13
+
14
+ def _capabilities_of(cls: type) -> list[dict]:
15
+ """(id, description) for each capability an adapter class exposes.
16
+
17
+ First reads ``@capability`` method descriptors statically — config-free, no
18
+ instantiation (so adapters that need API keys/backends still list). If none are found,
19
+ the adapter likely builds its capabilities dynamically in ``capabilities()`` (e.g.
20
+ chp-core's GitAdapter via git_capabilities()); a static scan can't see those, so it
21
+ showed "0 caps". Try a SAFE no-arg instantiation to read them — an adapter that needs
22
+ configuration raises on ``__init__``/``capabilities()`` and is caught, staying capless
23
+ exactly as before (the config-free contract holds; only no-arg adapters gain coverage).
24
+ """
25
+ caps: list[dict] = []
26
+ seen: set[str] = set()
27
+ for _name, member in inspect.getmembers(cls):
28
+ descriptor = getattr(member, "__chp_descriptor__", None)
29
+ cid = getattr(descriptor, "id", None)
30
+ if cid and cid not in seen:
31
+ seen.add(cid)
32
+ caps.append({"id": cid, "description": (descriptor.description or "").strip()})
33
+ if not caps:
34
+ try:
35
+ for hosted in cls().capabilities(): # dynamic-capabilities() adapters
36
+ descriptor = getattr(hosted, "descriptor", None)
37
+ cid = getattr(descriptor, "id", None)
38
+ if cid and cid not in seen:
39
+ seen.add(cid)
40
+ caps.append({"id": cid,
41
+ "description": (getattr(descriptor, "description", "") or "").strip()})
42
+ except Exception:
43
+ pass # needs config (or any error) -> report capless, config-free, unchanged
44
+ return sorted(caps, key=lambda c: c["id"])
45
+
46
+
47
+ def adapter_catalog() -> list[dict]:
48
+ """Installed adapters as ``[{name, module, capabilities: [{id, description}]}]``, sorted by
49
+ name. Empty when no adapter packages are installed (a bare chp-server + chp-core env)."""
50
+ from chp_core.adapters import discover_adapters
51
+
52
+ catalog: list[dict] = []
53
+ for name, cls in sorted(discover_adapters().items()):
54
+ try:
55
+ caps = _capabilities_of(cls)
56
+ except Exception:
57
+ caps = [] # a broken adapter never sinks the catalog
58
+ catalog.append({"name": name, "module": getattr(cls, "__module__", "?"),
59
+ "capabilities": caps})
60
+ return catalog
chp_server/_example.py ADDED
@@ -0,0 +1,45 @@
1
+ """Sample capabilities for ``chp-server serve --example``.
2
+
3
+ A fresh ``chp serve`` starts a truthful ``protocol-only`` server where every
4
+ optional feature is ``unsupported`` — honest, but there is nothing to invoke
5
+ yet. ``--example`` attaches a governed host carrying a few harmless capabilities
6
+ so the very first run is *live* and curl-able, and so the quickstart in the
7
+ README works verbatim. It is a teaching aid, not a product surface.
8
+
9
+ chp-core only — no other CHP package is imported.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime, timezone
15
+
16
+ from chp_core import CapabilityDescriptor, LocalCapabilityHost, SQLiteEvidenceStore
17
+
18
+
19
+ def build_example_host(store_path: str, host_id: str = "chp-example") -> LocalCapabilityHost:
20
+ """A governed host with three sample capabilities: ``greet.hello``,
21
+ ``math.add`` and ``time.now``. Attach it with ``ExistingHostPort``."""
22
+ host = LocalCapabilityHost(host_id, store=SQLiteEvidenceStore(store_path))
23
+
24
+ host.register(
25
+ CapabilityDescriptor(
26
+ id="greet.hello", version="1.0.0", description="Greet a name.",
27
+ input_schema={"type": "object",
28
+ "properties": {"name": {"type": "string"}}}),
29
+ lambda ctx, payload: {"greeting": f"hello, {payload.get('name', 'world')}"},
30
+ )
31
+ host.register(
32
+ CapabilityDescriptor(
33
+ id="math.add", version="1.0.0", description="Add two numbers.",
34
+ input_schema={"type": "object",
35
+ "properties": {"a": {"type": "number"}, "b": {"type": "number"}}}),
36
+ lambda ctx, payload: {"sum": payload.get("a", 0) + payload.get("b", 0)},
37
+ )
38
+ host.register(
39
+ CapabilityDescriptor(
40
+ id="time.now", version="1.0.0",
41
+ description="Current UTC time as an ISO-8601 string."),
42
+ lambda ctx, payload: {
43
+ "now": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")},
44
+ )
45
+ return host
@@ -0,0 +1,69 @@
1
+ """Scaffold a starter CHP capability server — the ``chp-server new`` command.
2
+
3
+ Writes a single runnable Python file that serves one sample capability through the
4
+ full CHP pipeline, so a newcomer goes from install to *their own* governed server in
5
+ one command. The generated file needs only chp-core + chp-server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ # Placeholders are substituted by str.replace (not str.format) so the template can
14
+ # contain literal f-strings and dict/JSON braces without escaping every one.
15
+ _TEMPLATE = '''#!/usr/bin/env python3
16
+ """A starter CHP capability server. Run it: python __FILENAME__
17
+
18
+ Serves your capabilities over HTTP with governed invocation and a signed, replayable
19
+ evidence chain. Needs only chp-core + chp-server. See the serving guide:
20
+ https://github.com/capabilityhostprotocol/chp-core
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from chp_server import CapabilityServer
25
+
26
+ app = CapabilityServer("__HOST_ID__")
27
+
28
+
29
+ @app.capability("greet.hello")
30
+ def hello(name: str = "world") -> dict:
31
+ """Greet a name."""
32
+ # The docstring becomes the description; the type hints become the input
33
+ # schema, which the pipeline enforces before this function runs.
34
+ return {"greeting": f"hello, {name}"}
35
+
36
+
37
+ # TODO: add your own capabilities — one decorated function each.
38
+
39
+
40
+ if __name__ == "__main__":
41
+ # try: curl localhost:8800/invoke -H 'Content-Type: application/json' \\
42
+ # -d '{"capability_id": "greet.hello", "payload": {"name": "CHP"}}'
43
+ app.run(port=8800)
44
+ '''
45
+
46
+
47
+ def _host_id(name: str) -> str:
48
+ """A safe host_id/slug from the requested name (letters, digits, dashes)."""
49
+ slug = re.sub(r"[^a-z0-9-]+", "-", Path(name).stem.lower()).strip("-")
50
+ return slug or "my-host"
51
+
52
+
53
+ def _target_path(name: str) -> Path:
54
+ return Path(name if name.endswith(".py") else f"{name}.py")
55
+
56
+
57
+ def render_starter(name: str) -> str:
58
+ """The generated starter file's source (does not touch disk)."""
59
+ path = _target_path(name)
60
+ return _TEMPLATE.replace("__FILENAME__", path.name).replace("__HOST_ID__", _host_id(name))
61
+
62
+
63
+ def write_starter(name: str, *, force: bool = False) -> Path:
64
+ """Write the starter file next to the caller; refuse to clobber unless *force*."""
65
+ path = _target_path(name)
66
+ if path.exists() and not force:
67
+ raise FileExistsError(f"{path} already exists (use --force to overwrite)")
68
+ path.write_text(render_starter(name))
69
+ return path
chp_server/app.py ADDED
@@ -0,0 +1,132 @@
1
+ """``CapabilityServer`` — the clean, decorator-first way to surface capabilities.
2
+
3
+ from chp_server import CapabilityServer
4
+
5
+ app = CapabilityServer("my-host")
6
+
7
+ @app.capability("math.add")
8
+ def add(a: int, b: int) -> dict:
9
+ "Add two numbers."
10
+ return {"sum": a + b}
11
+
12
+ app.run(port=8800)
13
+
14
+ `app.capability(...)` registers a governed capability from a plain function:
15
+
16
+ - the **docstring** becomes the description,
17
+ - the **type hints** become the input schema — and the pipeline *enforces* it: a
18
+ malformed call is denied (`input_schema_validation_failed`) before your handler runs,
19
+ - payload fields arrive as **keyword arguments** (write `def add(a, b)`, not `payload`).
20
+
21
+ It is thin sugar over `LocalCapabilityHost` + `Server.serving`; reach for the explicit
22
+ host/attachment API (`ExistingHostPort`, resolution, federation, the distribute path,
23
+ custom evidence stores) when you outgrow it. chp-core only.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import inspect
29
+ from typing import TYPE_CHECKING, Any, Callable
30
+
31
+ from chp_core import LocalCapabilityHost, SQLiteEvidenceStore
32
+ from chp_core.decorators import capability as _capability
33
+
34
+ from .server import Server
35
+
36
+ if TYPE_CHECKING:
37
+ from chp_core import CapabilityDescriptor
38
+
39
+
40
+ class CapabilityServer:
41
+ """A host you decorate capabilities onto, then serve."""
42
+
43
+ def __init__(self, host_id: str = "chp-server", *, store: str | None = None) -> None:
44
+ self.host = LocalCapabilityHost(
45
+ host_id, store=SQLiteEvidenceStore(store or f"{host_id}.sqlite"))
46
+
47
+ def capability(self, id: str, *, version: str = "1.0.0",
48
+ description: str | None = None,
49
+ input_schema: dict | bool | None = None,
50
+ **descriptor_kwargs: Any) -> Callable[[Callable], Callable]:
51
+ """Register the decorated function as a governed capability.
52
+
53
+ ``description`` defaults to the function's docstring; ``input_schema`` defaults
54
+ to one inferred from its type hints (pass a dict to override, or ``False`` to
55
+ declare none). Any other ``CapabilityDescriptor`` field (``policy``, ``risk``,
56
+ ``tags``, ``timeout_s``, ...) may be passed through.
57
+ """
58
+ def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
59
+ desc = description or (inspect.getdoc(fn) or "").strip() or id
60
+ # input_schema None -> infer from type hints (chp-core does it);
61
+ # False -> declare none; a dict -> use it verbatim.
62
+ explicit = input_schema if isinstance(input_schema, dict) else None
63
+ decorated = _capability(
64
+ id=id, version=version, description=desc,
65
+ input_schema=explicit, infer_schema=(input_schema is None),
66
+ **descriptor_kwargs)(fn)
67
+ self.host.register(decorated)
68
+ return decorated
69
+ return decorate
70
+
71
+ def compose(self, adapter: Any, *, replace: bool = False) -> list[CapabilityDescriptor]:
72
+ """Compose an EXISTING adapter's capabilities onto this server — the adapter-first
73
+ way to add git / http / secrets / filesystem / … without reimplementing them. A
74
+ server serves whatever mix it needs: hand-written ``@capability`` functions *and*
75
+ any ``chp-adapter-*`` (a ``chp_core.adapters.BaseAdapter`` of ``@capability``
76
+ methods). One governed implementation, reused here — not a parallel copy. Returns
77
+ the registered capability descriptors; ``replace=True`` overwrites duplicates (e.g.
78
+ an adapter re-created with new config)."""
79
+ from chp_core import register_adapter
80
+ return register_adapter(self.host, adapter, replace=replace)
81
+
82
+ def serving(self, **serving_kwargs: Any) -> Server:
83
+ """Build (do not start) a Server projecting this host — for embedding/tests."""
84
+ return Server.serving(self.host, **serving_kwargs)
85
+
86
+ def _served_capability_ids(self) -> list[str]:
87
+ """Best-effort list of capability ids the host serves (for the run() banner).
88
+
89
+ host.discover() returns a HostDescriptor dict ({..., "capabilities": [{"id": ...}]});
90
+ tolerate an object shape too so this never breaks the banner.
91
+ """
92
+ try:
93
+ desc = self.host.discover()
94
+ except Exception:
95
+ return []
96
+ caps = (desc.get("capabilities") if isinstance(desc, dict)
97
+ else getattr(desc, "capabilities", None)) or []
98
+ ids = []
99
+ for c in caps:
100
+ cid = c.get("id") if isinstance(c, dict) else getattr(c, "id", None)
101
+ if cid:
102
+ ids.append(cid)
103
+ return sorted(ids)
104
+
105
+ def run(self, *, port: int = 8800, bind: str = "127.0.0.1",
106
+ **serving_kwargs: Any) -> None:
107
+ """Serve this host over HTTP (blocking)."""
108
+ server = self.serving(port=port, bind=bind, **serving_kwargs)
109
+ server.start()
110
+ base = f"http://{bind}:{server.port}"
111
+ caps = self._served_capability_ids()
112
+ # A first-run banner that SHOWS the payoff: the URL, what's served, a ready-to-paste
113
+ # invoke, and the replay hint (the evidence chain is the point). flush=True so it
114
+ # appears immediately even when stdout is piped/redirected, not just on a tty.
115
+ print(f"chp-server serving at {base} (Ctrl-C to stop)", flush=True)
116
+ if caps:
117
+ shown = ", ".join(caps[:8]) + (f" (+{len(caps) - 8} more)" if len(caps) > 8 else "")
118
+ print(f" {len(caps)} capabilit{'y' if len(caps) == 1 else 'ies'}: {shown}", flush=True)
119
+ print(" try it:", flush=True)
120
+ print(f" curl -s {base}/invoke -H 'Content-Type: application/json' \\", flush=True)
121
+ print(f" -d '{{\"capability_id\": \"{caps[0]}\", \"payload\": {{}}}}'", flush=True)
122
+ print(f" curl -s {base}/replay/<correlation_id> # the signed evidence chain",
123
+ flush=True)
124
+ else:
125
+ print(f" no capabilities registered — GET {base}/server for honest feature truth",
126
+ flush=True)
127
+ try:
128
+ server.serve_forever()
129
+ except KeyboardInterrupt:
130
+ pass
131
+ finally:
132
+ server.stop()
chp_server/cli.py ADDED
@@ -0,0 +1,163 @@
1
+ """chp-server CLI — `chp serve` (via the chp-core shim) lands here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+
10
+ def main(argv: list[str] | None = None) -> int:
11
+ parser = argparse.ArgumentParser(
12
+ prog="chp-server",
13
+ description="Reference CHP server: chp-core protocol surface + optional attachments.")
14
+ sub = parser.add_subparsers(dest="command", required=True)
15
+
16
+ serve = sub.add_parser("serve", help="Start the server (blocking).")
17
+ serve.add_argument("--bind", default=None)
18
+ serve.add_argument("--port", type=int, default=None)
19
+ serve.add_argument("--profile", default=None)
20
+ serve.add_argument("--config", default=None, metavar="FILE", help="JSON config file")
21
+ serve.add_argument("--store", default=None, metavar="PATH", help="Evidence store path")
22
+ serve.add_argument("--example", action="store_true",
23
+ help="Attach a few sample capabilities (greet.hello, math.add, "
24
+ "time.now) so the server is live and curl-able on first run.")
25
+
26
+ describe = sub.add_parser("describe", help="Print Server.Describe for a config without serving.")
27
+ describe.add_argument("--config", default=None, metavar="FILE")
28
+ describe.add_argument("--profile", default=None)
29
+
30
+ new = sub.add_parser("new", help="Scaffold a runnable starter capability server.")
31
+ new.add_argument("name", help="Starter file to create, e.g. 'mycaps' or 'mycaps.py'.")
32
+ new.add_argument("--force", action="store_true", help="Overwrite if the file exists.")
33
+
34
+ adapters = sub.add_parser(
35
+ "adapters", help="List installed chp-adapter-* capability sets you can compose().")
36
+ adapters.add_argument("--verbose", action="store_true",
37
+ help="Show each capability id + description.")
38
+ adapters.add_argument("--json", action="store_true", help="Emit the catalog as JSON.")
39
+
40
+ replay = sub.add_parser(
41
+ "replay", help="Pretty-print a GET /replay evidence chain (JSON from stdin or --file).")
42
+ replay.add_argument("--file", default=None, metavar="PATH",
43
+ help="Read the /replay JSON from a file instead of stdin.")
44
+
45
+ args = parser.parse_args(argv)
46
+
47
+ if args.command == "replay":
48
+ from .replay import Replay
49
+ if args.file:
50
+ try:
51
+ raw = open(args.file).read()
52
+ except OSError as exc:
53
+ print(f"ERROR: {exc}", file=sys.stderr)
54
+ return 1
55
+ elif sys.stdin.isatty():
56
+ # No --file and an interactive stdin: NEVER block on stdin.read() (that hung a
57
+ # CLI invocation indefinitely). Show usage and exit instead.
58
+ print("chp-server replay: pipe a /replay response, or pass --file. e.g.\n"
59
+ " curl -s http://127.0.0.1:8800/replay/<correlation_id> | chp-server replay\n"
60
+ " chp-server replay --file replay.json", file=sys.stderr)
61
+ return 2
62
+ else:
63
+ raw = sys.stdin.read()
64
+ try:
65
+ doc = json.loads(raw)
66
+ except json.JSONDecodeError as exc:
67
+ print(f"ERROR: input is not valid JSON ({exc}). Pipe a /replay response, e.g.:\n"
68
+ " curl -s http://127.0.0.1:8800/replay/<correlation_id> | chp-server replay",
69
+ file=sys.stderr)
70
+ return 1
71
+ print(Replay.from_wire(doc).render())
72
+ return 0
73
+
74
+ if args.command == "adapters":
75
+ from ._catalog import adapter_catalog
76
+ catalog = adapter_catalog()
77
+ if getattr(args, "json", False):
78
+ print(json.dumps(catalog, indent=2))
79
+ return 0
80
+ if not catalog:
81
+ print("No chp-adapter-* packages installed in this environment.\n"
82
+ "Install some (e.g. pip install chp-adapter-filesystem), then compose them:\n"
83
+ " app.compose(FilesystemAdapter(...))")
84
+ return 0
85
+ total = sum(len(a["capabilities"]) for a in catalog)
86
+ print(f"{len(catalog)} adapters installed ({total} capabilities) — "
87
+ "compose with app.compose(AdapterClass(...)):\n")
88
+ for a in catalog:
89
+ print(f" {a['name']:24} {len(a['capabilities']):>3} caps")
90
+ if getattr(args, "verbose", False):
91
+ for c in a["capabilities"]:
92
+ print(f" {c['id']} — {c['description'][:68]}")
93
+ return 0
94
+
95
+ if args.command == "new":
96
+ from ._scaffold import write_starter
97
+ try:
98
+ path = write_starter(args.name, force=args.force)
99
+ except OSError as exc:
100
+ print(f"ERROR: {exc}", file=sys.stderr)
101
+ return 1
102
+ print(f"Created {path} — run it with: python {path}")
103
+ return 0
104
+
105
+ from .config import ServerConfig
106
+ from .server import Server
107
+
108
+ # --example needs a host to attach to; default it into the `host` profile
109
+ # unless the caller chose one explicitly.
110
+ example = getattr(args, "example", False)
111
+ profile = args.profile or ("host" if example else None)
112
+
113
+ try:
114
+ config = ServerConfig.from_sources(
115
+ config_file=args.config, bind=getattr(args, "bind", None),
116
+ port=getattr(args, "port", None), profile=profile,
117
+ store=getattr(args, "store", None))
118
+ except (ValueError, OSError) as exc:
119
+ print(f"ERROR: {exc}", file=sys.stderr)
120
+ return 1
121
+
122
+ server = Server(config)
123
+
124
+ example_dir = None
125
+ if example:
126
+ import os
127
+ import tempfile
128
+ from ._example import build_example_host
129
+ from .local import ExistingHostPort
130
+ example_dir = tempfile.mkdtemp(prefix="chp-example-")
131
+ server.attach(ExistingHostPort(
132
+ build_example_host(os.path.join(example_dir, "example.sqlite"))))
133
+
134
+ if args.command == "describe":
135
+ print(json.dumps(server.describe(), indent=2))
136
+ return 0
137
+
138
+ try:
139
+ server.start()
140
+ except RuntimeError as exc: # fail-closed profile validation
141
+ print(f"ERROR: {exc}", file=sys.stderr)
142
+ return 1
143
+ print(f"chp-server {server.identity.instance_id} serving profile "
144
+ f"{config.profile!r} at http://{config.bind}:{server.port}")
145
+ print("Routes: GET /health /ready /server /host /capabilities, POST /invoke")
146
+ if example:
147
+ base = f"http://{config.bind}:{server.port}"
148
+ print("\nSample capabilities attached — try:")
149
+ print(f" curl {base}/capabilities.txt")
150
+ print(f" curl {base}/invoke -H 'Content-Type: application/json' \\")
151
+ print(" -d '{\"capability_id\": \"greet.hello\", \"payload\": {\"name\": \"CHP\"}}'")
152
+ try:
153
+ server.serve_forever()
154
+ except KeyboardInterrupt:
155
+ pass
156
+ finally:
157
+ server.stop()
158
+ print("\nStopped chp-server.")
159
+ return 0
160
+
161
+
162
+ if __name__ == "__main__":
163
+ raise SystemExit(main())
chp_server/config.py ADDED
@@ -0,0 +1,77 @@
1
+ """ServerConfig — deterministic precedence + generations (doc 40).
2
+
3
+ Precedence: built-in defaults -> config file (JSON) -> environment -> explicit
4
+ programmatic overrides. Effective configuration is inspectable with secrets
5
+ redacted; unknown fields fail validation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field, fields
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from chp_core.environment import current_environment, validate_environment
17
+
18
+ _ENV_PREFIX = "CHP_SERVER_"
19
+ _SECRET_FIELDS = ("tls_keyfile",)
20
+
21
+
22
+ @dataclass
23
+ class ServerConfig:
24
+ bind: str = "127.0.0.1"
25
+ port: int = 8770
26
+ profile: str = "protocol-only"
27
+ environment: str = field(default_factory=current_environment)
28
+ host_id: str = "chp-server"
29
+ store: str | None = None # evidence store path; None = chp-core default
30
+ tls_certfile: str | None = None
31
+ tls_keyfile: str | None = None
32
+ tls_cafile: str | None = None
33
+ # attachments: {entry_point_name: {kwargs}} — Phase B config (doc 40 §3);
34
+ # only listed attachments are loaded even when more are installed.
35
+ attachments: dict[str, dict] = field(default_factory=dict)
36
+ # HA multi-instance (HA-003): when enabled, instances of one logical Host contend
37
+ # for a store-backed ownership lease; only the ACTIVE holder admits consequential
38
+ # work, standby refuses server_not_active. Requires a SHARED `store` all instances
39
+ # point at. Off = single-instance (always active). ttl = lease/heartbeat window.
40
+ ha_enabled: bool = False
41
+ ha_lease_ttl_s: float = 30.0
42
+
43
+ def __post_init__(self) -> None:
44
+ validate_environment(self.environment)
45
+ from .profiles import PROFILES
46
+ if self.profile not in PROFILES:
47
+ raise ValueError(f"unknown profile {self.profile!r}; valid: {sorted(PROFILES)}")
48
+
49
+ @classmethod
50
+ def from_sources(cls, config_file: str | Path | None = None,
51
+ env: dict[str, str] | None = None, **overrides: Any) -> "ServerConfig":
52
+ env = os.environ if env is None else env
53
+ known = {f.name for f in fields(cls)}
54
+ merged: dict[str, Any] = {}
55
+ if config_file:
56
+ data = json.loads(Path(config_file).read_text())
57
+ unknown = set(data) - known
58
+ if unknown: # unknown fields fail closed (doc 40 §7)
59
+ raise ValueError(f"unknown configuration fields: {sorted(unknown)}")
60
+ merged.update(data)
61
+ for f in fields(cls):
62
+ key = _ENV_PREFIX + f.name.upper()
63
+ if key in env:
64
+ raw = env[key]
65
+ merged[f.name] = int(raw) if f.type == "int" else raw
66
+ merged.update({k: v for k, v in overrides.items() if v is not None})
67
+ unknown = set(merged) - known
68
+ if unknown:
69
+ raise ValueError(f"unknown configuration fields: {sorted(unknown)}")
70
+ return cls(**merged)
71
+
72
+ def redacted(self) -> dict:
73
+ d = {f.name: getattr(self, f.name) for f in fields(self)}
74
+ for k in _SECRET_FIELDS:
75
+ if d.get(k):
76
+ d[k] = "<redacted>"
77
+ return d