oc_connect
Loading...
Searching...
No Matches
workspace.py
Go to the documentation of this file.
1"""Workspace-anchored path resolution for every piece of connector state.
2
3CONNECT-02 defect 1 (proposal V1 §7). Before this module the state directory
4was a module-level ``Path(os.environ.get(..., ".option_c/orchestration/oc_connect"))``
5evaluated once at import time and interpreted against the *current working
6directory*. Two failures followed from the one expression: a send issued from
7any subdirectory looked for connector state in a directory that has none and
8died with ``Telegram destination 'operator' is not configured``, and the value
9could not be re-resolved after import even when the process changed directory.
10
11Resolution here is per call and anchored by marker walk, the way OCT's own
12project-root discovery works:
13
141. an absolute ``OC_CONNECT_STATE_DIR`` wins outright (an explicit operator
15 override needs no discovery);
162. otherwise walk upward from *start* for ``.option_c_workspace.json`` — the
17 workspace marker wins even when a nearer subproject carries its own
18 ``.option_c/``, because connector authority is workspace-wide, not
19 per-subproject;
203. failing that, the nearest ancestor holding an ``.option_c/`` directory;
214. failing that, raise. Guessing a root would put operator grants somewhere the
22 operator cannot find them.
23
24A *relative* ``OC_CONNECT_STATE_DIR`` resolves against the workspace root, not
25the cwd — reinstating cwd-relative behaviour through the override would
26reintroduce exactly the defect this module removes.
27
28Dependencies
29------------
30- oc_diagnostics (_dbg structured logger)
31"""
32from __future__ import annotations
33
34import os
35from pathlib import Path
36
37from oc_diagnostics import _dbg
38
39WORKSPACE_MARKER = ".option_c_workspace.json"
40PROJECT_MARKER = ".option_c"
41STATE_SUBPATH = (".option_c", "orchestration", "oc_connect")
42CREDENTIAL_SUBPATH = (".option_c", "connector_secrets.json")
43
44#: The comms bus lives beside the dev engine's orchestration state
45#: (proposal V1 §3, verbatim layout). Workspaces without an ``oc_chat``
46#: subproject fall back to the workspace-level ``.option_c/`` so the bus is
47#: still anchored rather than cwd-relative.
48COMMS_ANCHOR = "oc_chat"
49COMMS_SUBPATH = (".option_c", "comms")
50
51STATE_DIR_ENV = "OC_CONNECT_STATE_DIR"
52CREDENTIAL_ENV = "OC_CONNECT_SECRETS"
53
54
55class WorkspaceNotFoundError(RuntimeError):
56 """No workspace or Option C project marker was found above the start path."""
57
58
59def _resolved(path: str | Path) -> Path:
60 candidate = Path(path).expanduser()
61 try:
62 return candidate.resolve()
63 except OSError:
64 return candidate.absolute()
65
66
67def find_workspace_root(start: str | Path | None = None) -> Path:
68 """Return the workspace root above *start*, failing loudly when absent."""
69 func = "find_workspace_root"
70 origin = _resolved(start if start is not None else Path.cwd())
71 project_fallback: Path | None = None
72 for parent in (origin, *origin.parents):
73 if (parent / WORKSPACE_MARKER).is_file():
74 _dbg(
75 "OC_CONNECT.WORKSPACE",
76 func,
77 f"anchored on workspace marker root={parent}",
78 3,
79 )
80 return parent
81 if project_fallback is None and (parent / PROJECT_MARKER).is_dir():
82 project_fallback = parent
83 if project_fallback is not None:
84 _dbg(
85 "OC_CONNECT.WORKSPACE",
86 func,
87 f"anchored on project marker root={project_fallback}",
88 3,
89 )
90 return project_fallback
92 f"no Option C workspace marker ({WORKSPACE_MARKER}) or project marker "
93 f"({PROJECT_MARKER}/) was found at or above {origin}"
94 )
95
96
97def resolve_root(root: str | Path | None = None) -> Path:
98 """Return an explicit root when given, otherwise discover one."""
99 if root is not None:
100 return _resolved(root)
101 return find_workspace_root()
102
103
104def _from_env(name: str, root: str | Path | None) -> Path | None:
105 raw = os.environ.get(name)
106 if raw is None or not raw.strip():
107 return None
108 override = Path(raw.strip()).expanduser()
109 if override.is_absolute():
110 return _resolved(override)
111 return resolve_root(root) / override
112
113
114def state_dir(root: str | Path | None = None) -> Path:
115 """Return the connector state directory for grants and destinations."""
116 override = _from_env(STATE_DIR_ENV, root)
117 if override is not None:
118 return override
119 return resolve_root(root).joinpath(*STATE_SUBPATH)
120
121
122def credential_path(root: str | Path | None = None) -> Path:
123 """Return the credential sidecar path, anchored the same way as state."""
124 override = _from_env(CREDENTIAL_ENV, root)
125 if override is not None:
126 return override
127 return resolve_root(root).joinpath(*CREDENTIAL_SUBPATH)
128
129
130def comms_root(root: str | Path | None = None) -> Path:
131 """Return the comms bus root for this workspace."""
132 anchor = resolve_root(root)
133 if (anchor / COMMS_ANCHOR).is_dir():
134 return anchor.joinpath(COMMS_ANCHOR, *COMMS_SUBPATH)
135 return anchor.joinpath(*COMMS_SUBPATH)
136
137
138__all__ = [
139 "COMMS_ANCHOR",
140 "COMMS_SUBPATH",
141 "CREDENTIAL_ENV",
142 "CREDENTIAL_SUBPATH",
143 "PROJECT_MARKER",
144 "STATE_DIR_ENV",
145 "STATE_SUBPATH",
146 "WORKSPACE_MARKER",
147 "WorkspaceNotFoundError",
148 "comms_root",
149 "credential_path",
150 "find_workspace_root",
151 "resolve_root",
152 "state_dir",
153]
Path comms_root(str|Path|None root=None)
Definition workspace.py:130
Path|None _from_env(str name, str|Path|None root)
Definition workspace.py:104
Path credential_path(str|Path|None root=None)
Definition workspace.py:122
Path resolve_root(str|Path|None root=None)
Definition workspace.py:97
Path state_dir(str|Path|None root=None)
Definition workspace.py:114
Path find_workspace_root(str|Path|None start=None)
Definition workspace.py:67
Path _resolved(str|Path path)
Definition workspace.py:59