soliplex-template 0.12.1__tar.gz → 0.14.dev0__tar.gz
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.
- {soliplex_template-0.12.1/src/soliplex_template.egg-info → soliplex_template-0.14.dev0}/PKG-INFO +3 -1
- {soliplex_template-0.12.1 → soliplex_template-0.14.dev0}/README.md +2 -0
- {soliplex_template-0.12.1 → soliplex_template-0.14.dev0}/pyproject.toml +21 -23
- {soliplex_template-0.12.1 → soliplex_template-0.14.dev0}/src/soliplex_template/gitea.py +60 -40
- {soliplex_template-0.12.1 → soliplex_template-0.14.dev0}/src/soliplex_template/secrets.py +6 -4
- {soliplex_template-0.12.1 → soliplex_template-0.14.dev0/src/soliplex_template.egg-info}/PKG-INFO +3 -1
- {soliplex_template-0.12.1 → soliplex_template-0.14.dev0}/src/soliplex_template.egg-info/SOURCES.txt +0 -1
- soliplex_template-0.12.1/src/soliplex_template/rooms.py +0 -218
- {soliplex_template-0.12.1 → soliplex_template-0.14.dev0}/LICENSE +0 -0
- {soliplex_template-0.12.1 → soliplex_template-0.14.dev0}/setup.cfg +0 -0
- {soliplex_template-0.12.1 → soliplex_template-0.14.dev0}/src/soliplex_template.egg-info/dependency_links.txt +0 -0
- {soliplex_template-0.12.1 → soliplex_template-0.14.dev0}/src/soliplex_template.egg-info/top_level.txt +0 -0
{soliplex_template-0.12.1/src/soliplex_template.egg-info → soliplex_template-0.14.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: soliplex-template
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.dev0
|
|
4
4
|
Summary: Docker Compose template for a Soliplex stack, plus the soliplex-template Agent Skill and its build tooling.
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -19,6 +19,8 @@ There are two ways to use it: run this repository directly as a reference stack
|
|
|
19
19
|
to try Soliplex now, or generate your own tailored stack from it (for a real
|
|
20
20
|
deployment). Both are below.
|
|
21
21
|
|
|
22
|
+
📖 **Full documentation:** <https://soliplex.github.io/soliplex-template/>
|
|
23
|
+
|
|
22
24
|
## Try it
|
|
23
25
|
|
|
24
26
|
You need Docker (with the Compose plugin), [`uv`](https://docs.astral.sh/uv/),
|
|
@@ -10,6 +10,8 @@ There are two ways to use it: run this repository directly as a reference stack
|
|
|
10
10
|
to try Soliplex now, or generate your own tailored stack from it (for a real
|
|
11
11
|
deployment). Both are below.
|
|
12
12
|
|
|
13
|
+
📖 **Full documentation:** <https://soliplex.github.io/soliplex-template/>
|
|
14
|
+
|
|
13
15
|
## Try it
|
|
14
16
|
|
|
15
17
|
You need Docker (with the Compose plugin), [`uv`](https://docs.astral.sh/uv/),
|
|
@@ -4,12 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "soliplex-template"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.14dev0"
|
|
8
8
|
description = "Docker Compose template for a Soliplex stack, plus the soliplex-template Agent Skill and its build tooling."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
|
-
# The 'soliplex_template' package (src/) is stdlib-only; the bundled skill's
|
|
12
|
-
# add_room.py imports 'soliplex_template.rooms' from it.
|
|
13
11
|
dependencies = []
|
|
14
12
|
|
|
15
13
|
# Tooling for the soliplex-template skill. The scripts also carry PEP 723
|
|
@@ -34,32 +32,32 @@ dev = [
|
|
|
34
32
|
"pytest",
|
|
35
33
|
"pytest-cov",
|
|
36
34
|
"ruff",
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"soliplex-skills >= 0.4",
|
|
35
|
+
"skills-ref==0.1.1", # 'generate_soliplex_project.py'
|
|
36
|
+
"soliplex-skills >= 0.5",
|
|
37
|
+
"soliplex-plumber >= 0.4", # skill's 'add_room.py'/'soliplex_config.py'
|
|
41
38
|
"zensical",
|
|
42
39
|
]
|
|
43
40
|
|
|
44
|
-
# The repo is configuration + Dockerfiles + skill source, but it also ships
|
|
45
|
-
# importable 'soliplex_template' package (src/), so it builds as a wheel.
|
|
46
|
-
# namespace package (no __init__.py); clients import the module directly, e.g.
|
|
47
|
-
# 'from soliplex_template.rooms import ...'.
|
|
41
|
+
# The repo is configuration + Dockerfiles + skill source, but it also ships
|
|
42
|
+
# the importable 'soliplex_template' package (src/), so it builds as a wheel.
|
|
48
43
|
[tool.setuptools.packages.find]
|
|
49
44
|
where = ["src"]
|
|
50
45
|
namespaces = true
|
|
51
46
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
47
|
+
# Required 100% unit test statement/branch coverage:
|
|
48
|
+
#
|
|
49
|
+
# --cov=scripts measures the repo-level build tooling;
|
|
50
|
+
# --cov=skill/scripts the bundled generator/version helpers;
|
|
51
|
+
# --cov=tests/unit keeps the test files themselves dead-code-free.
|
|
52
|
+
#
|
|
53
|
+
# Functional tests live under tests/functional/ and are opt-in: they generate
|
|
54
|
+
# a real project and shell out to git/openssl/docker). Run them explicitly
|
|
55
|
+
# with: # 'uv run --group dev pytest tests/functional --no-cov'
|
|
56
|
+
#
|
|
57
|
+
# (--no-cov because the generator runs in a child process, so it isn't
|
|
58
|
+
# measured and would otherwise trip --cov-fail-under).
|
|
56
59
|
[tool.pytest.ini_options]
|
|
57
60
|
python_files = "test_*.py"
|
|
58
|
-
# Functional tests live under tests/functional/ and are opt-in (they generate a
|
|
59
|
-
# real project and shell out to git/openssl/docker). Run them explicitly with
|
|
60
|
-
# uv run --group dev pytest tests/functional --no-cov
|
|
61
|
-
# (--no-cov because the generator runs in a child process, so it isn't measured
|
|
62
|
-
# and would otherwise trip --cov-fail-under). They are not collected by default.
|
|
63
61
|
testpaths = ["tests/unit"]
|
|
64
62
|
addopts = "--cov=scripts --cov=skills/soliplex-template/scripts --cov=src/soliplex_template --cov=tests/unit --cov-branch --cov-fail-under=100"
|
|
65
63
|
markers = [
|
|
@@ -69,9 +67,9 @@ markers = [
|
|
|
69
67
|
[tool.coverage.report]
|
|
70
68
|
show_missing = true
|
|
71
69
|
|
|
72
|
-
#
|
|
73
|
-
# single-line imports, and pytest-style tweaks). ruff-format is enforced by
|
|
74
|
-
# pre-commit hook; `ruff check` uses these rules on demand.
|
|
70
|
+
# Mirror soliplex/soliplex linting (same rule selection, line length,
|
|
71
|
+
# single-line imports, and pytest-style tweaks). ruff-format is enforced by
|
|
72
|
+
# the pre-commit hook; `ruff check` uses these rules on demand.
|
|
75
73
|
[tool.ruff]
|
|
76
74
|
line-length = 79
|
|
77
75
|
exclude = [
|
|
@@ -63,58 +63,78 @@ READY_DELAY = 2
|
|
|
63
63
|
class GiteaError(Exception):
|
|
64
64
|
"""A user-facing error (printed without a traceback)."""
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
|
|
67
|
+
class GiteaNotReady(GiteaError):
|
|
68
|
+
def __init__(self, url):
|
|
69
|
+
self.url = url
|
|
70
|
+
super().__init__(
|
|
69
71
|
f"Gitea did not become ready at {url} "
|
|
70
72
|
"(is the stack up? 'docker compose up -d gitea')"
|
|
71
73
|
)
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
|
|
76
|
+
class ReservedUser(GiteaError):
|
|
77
|
+
def __init__(self, name):
|
|
78
|
+
self.name = name
|
|
79
|
+
super().__init__(
|
|
76
80
|
f"web-UI admin user may not be {name!r}: it is the rotating "
|
|
77
81
|
"service account"
|
|
78
82
|
)
|
|
79
83
|
|
|
80
|
-
@classmethod
|
|
81
|
-
def password_mismatch(cls):
|
|
82
|
-
return cls("passwords did not match")
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
def
|
|
86
|
-
|
|
85
|
+
class PasswordMismatch(GiteaError):
|
|
86
|
+
def __init__(self):
|
|
87
|
+
super().__init__("passwords did not match")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TokenRequestFailed(GiteaError):
|
|
91
|
+
def __init__(self, code, body):
|
|
92
|
+
self.code = code
|
|
93
|
+
self.body = body
|
|
94
|
+
super().__init__(f"token request failed: HTTP {code}: {body}")
|
|
95
|
+
|
|
87
96
|
|
|
88
|
-
|
|
89
|
-
def
|
|
90
|
-
|
|
97
|
+
class NoToken(GiteaError):
|
|
98
|
+
def __init__(self, body):
|
|
99
|
+
self.body = body
|
|
100
|
+
super().__init__(f"could not parse token from response: {body}")
|
|
91
101
|
|
|
92
|
-
@classmethod
|
|
93
|
-
def repo_failed(cls, code):
|
|
94
|
-
return cls(f"repo creation failed (HTTP {code})")
|
|
95
102
|
|
|
96
|
-
|
|
97
|
-
def
|
|
98
|
-
|
|
103
|
+
class RepoCreationFailed(GiteaError):
|
|
104
|
+
def __init__(self, code):
|
|
105
|
+
self.code = code
|
|
106
|
+
super().__init__(f"repo creation failed (HTTP {code})")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class NotAGitRepo(GiteaError):
|
|
110
|
+
def __init__(self, path):
|
|
111
|
+
self.path = path
|
|
112
|
+
super().__init__(
|
|
99
113
|
f"{path} is not a git repository "
|
|
100
114
|
"(was the stack scaffolded with --no-git?)"
|
|
101
115
|
)
|
|
102
116
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
117
|
+
|
|
118
|
+
class No_SSH_hKeys(GiteaError):
|
|
119
|
+
def __init__(self):
|
|
120
|
+
super().__init__(
|
|
106
121
|
"no SSH public key found to register with Gitea: load one into "
|
|
107
122
|
"your agent (ssh-add), pass --ssh-key PATH, or create one "
|
|
108
123
|
"(ssh-keygen)"
|
|
109
124
|
)
|
|
110
125
|
|
|
111
|
-
@classmethod
|
|
112
|
-
def key_upload_failed(cls, code, body):
|
|
113
|
-
return cls(f"SSH key upload failed: HTTP {code}: {body}")
|
|
114
126
|
|
|
115
|
-
|
|
116
|
-
def
|
|
117
|
-
|
|
127
|
+
class KeyUploadFailed(GiteaError):
|
|
128
|
+
def __init__(self, code, body):
|
|
129
|
+
self.code = code
|
|
130
|
+
self.body = body
|
|
131
|
+
super().__init__(f"SSH key upload failed: HTTP {code}: {body}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class PushFailed(GiteaError):
|
|
135
|
+
def __init__(self, stderr):
|
|
136
|
+
self.stderr = stderr
|
|
137
|
+
super().__init__(f"git push to Gitea failed: {stderr}")
|
|
118
138
|
|
|
119
139
|
|
|
120
140
|
def generate_admin_password(length: int = 32) -> str:
|
|
@@ -169,10 +189,10 @@ def parse_token(body: str) -> str:
|
|
|
169
189
|
try:
|
|
170
190
|
data = json.loads(body)
|
|
171
191
|
except json.JSONDecodeError as exc:
|
|
172
|
-
raise
|
|
192
|
+
raise NoToken(body) from exc
|
|
173
193
|
token = data.get("sha1")
|
|
174
194
|
if not token:
|
|
175
|
-
raise
|
|
195
|
+
raise NoToken(body)
|
|
176
196
|
return token
|
|
177
197
|
|
|
178
198
|
|
|
@@ -231,7 +251,7 @@ def mint_token(password: str, *, token_name: str) -> str:
|
|
|
231
251
|
body = resp.read().decode()
|
|
232
252
|
except urllib.error.HTTPError as exc:
|
|
233
253
|
body = exc.read().decode()
|
|
234
|
-
raise
|
|
254
|
+
raise TokenRequestFailed(exc.code, body) from exc
|
|
235
255
|
return parse_token(body)
|
|
236
256
|
|
|
237
257
|
|
|
@@ -253,7 +273,7 @@ def create_repo(
|
|
|
253
273
|
except urllib.error.HTTPError as exc:
|
|
254
274
|
code = exc.code
|
|
255
275
|
if code not in (201, 409):
|
|
256
|
-
raise
|
|
276
|
+
raise RepoCreationFailed(code)
|
|
257
277
|
return code
|
|
258
278
|
|
|
259
279
|
|
|
@@ -314,7 +334,7 @@ def upload_ssh_key(public_key: str, *, password: str, title: str) -> None:
|
|
|
314
334
|
if exc.code == 422:
|
|
315
335
|
return
|
|
316
336
|
body = exc.read().decode()
|
|
317
|
-
raise
|
|
337
|
+
raise KeyUploadFailed(exc.code, body) from exc
|
|
318
338
|
|
|
319
339
|
|
|
320
340
|
def _git(*args, project_dir, env=None):
|
|
@@ -375,7 +395,7 @@ def push_initial(project_dir, branch: str) -> None:
|
|
|
375
395
|
"push", "-u", "origin", branch, project_dir=project_dir, env=env
|
|
376
396
|
)
|
|
377
397
|
if result.returncode != 0:
|
|
378
|
-
raise
|
|
398
|
+
raise PushFailed(result.stderr.strip())
|
|
379
399
|
|
|
380
400
|
|
|
381
401
|
def push_stack_to_gitea(
|
|
@@ -392,10 +412,10 @@ def push_stack_to_gitea(
|
|
|
392
412
|
"""
|
|
393
413
|
project = pathlib.Path(project_dir)
|
|
394
414
|
if not (project / ".git").is_dir():
|
|
395
|
-
raise
|
|
415
|
+
raise NotAGitRepo(project)
|
|
396
416
|
keys = discover_ssh_keys(ssh_key=ssh_key)
|
|
397
417
|
if not keys:
|
|
398
|
-
raise
|
|
418
|
+
raise No_SSH_hKeys()
|
|
399
419
|
print(f"Registering {len(keys)} SSH key(s) with '{ADMIN_USER}' ...")
|
|
400
420
|
for key in keys:
|
|
401
421
|
upload_ssh_key(key, password=password, title=_key_title(key))
|
|
@@ -482,12 +502,12 @@ def provision_gitea(
|
|
|
482
502
|
accounts) are the result.
|
|
483
503
|
"""
|
|
484
504
|
if webui_user == ADMIN_USER:
|
|
485
|
-
raise
|
|
505
|
+
raise ReservedUser(webui_user)
|
|
486
506
|
project = pathlib.Path(project_dir).resolve()
|
|
487
507
|
print("=== Gitea provisioning ===")
|
|
488
508
|
print(f"Waiting for Gitea at {GITEA_HTTP} ...")
|
|
489
509
|
if not wait_for_gitea():
|
|
490
|
-
raise
|
|
510
|
+
raise GiteaNotReady(GITEA_HTTP)
|
|
491
511
|
|
|
492
512
|
password = generate_admin_password()
|
|
493
513
|
print(f"Ensuring service account '{ADMIN_USER}' ...")
|
|
@@ -49,9 +49,11 @@ GEN_SUFFIX = ".gen"
|
|
|
49
49
|
class SecretsError(Exception):
|
|
50
50
|
"""A user-facing error (printed without a traceback)."""
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
|
|
53
|
+
class ComposeNotFound(SecretsError):
|
|
54
|
+
def __init__(self, path):
|
|
55
|
+
self.path = path
|
|
56
|
+
super().__init__(
|
|
55
57
|
f"cannot find compose file at: {path} "
|
|
56
58
|
"(run with --project-dir pointing at the stack directory)"
|
|
57
59
|
)
|
|
@@ -161,7 +163,7 @@ def generate_secrets(project_dir) -> None:
|
|
|
161
163
|
project = pathlib.Path(project_dir).resolve()
|
|
162
164
|
compose = project / COMPOSE_FILE
|
|
163
165
|
if not compose.is_file():
|
|
164
|
-
raise
|
|
166
|
+
raise ComposeNotFound(compose)
|
|
165
167
|
|
|
166
168
|
secrets_dir = project / SECRETS_DIR
|
|
167
169
|
secrets_dir.mkdir(parents=True, exist_ok=True)
|
{soliplex_template-0.12.1 → soliplex_template-0.14.dev0/src/soliplex_template.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: soliplex-template
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.dev0
|
|
4
4
|
Summary: Docker Compose template for a Soliplex stack, plus the soliplex-template Agent Skill and its build tooling.
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -19,6 +19,8 @@ There are two ways to use it: run this repository directly as a reference stack
|
|
|
19
19
|
to try Soliplex now, or generate your own tailored stack from it (for a real
|
|
20
20
|
deployment). Both are below.
|
|
21
21
|
|
|
22
|
+
📖 **Full documentation:** <https://soliplex.github.io/soliplex-template/>
|
|
23
|
+
|
|
22
24
|
## Try it
|
|
23
25
|
|
|
24
26
|
You need Docker (with the Compose plugin), [`uv`](https://docs.astral.sh/uv/),
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
"""Generic, template-agnostic logic for adding a room to a Soliplex stack.
|
|
2
|
-
|
|
3
|
-
This is the reusable core behind the ``soliplex-template`` skill's bundled
|
|
4
|
-
``add_room.py`` -- a PEP 723 shim that owns the ``.mako`` templates and the
|
|
5
|
-
CLI and delegates the stack-level work here. It ships in the published
|
|
6
|
-
``soliplex-template`` distribution so the skill (and any other consumer) can
|
|
7
|
-
``from soliplex_template.rooms import ...``.
|
|
8
|
-
|
|
9
|
-
It works on a *rendered* room config (text the caller produced however it likes
|
|
10
|
-
-- a template, an existing room's config, or built by hand) plus the stack's
|
|
11
|
-
``installation.yaml``, which it edits line-based (comment-preserving):
|
|
12
|
-
|
|
13
|
-
- ``resolve_project`` / ``resolve_package_name`` -- locate + introspect it.
|
|
14
|
-
- ``validate_room_id`` -- the room-id / path-segment rule.
|
|
15
|
-
- ``add_room_path`` -- ensure ``room_paths`` loads the room (``added`` /
|
|
16
|
-
``COVERED`` by a ``./rooms`` parent entry / ``unchanged`` when already
|
|
17
|
-
listed), preserving comments and layout.
|
|
18
|
-
- ``install_room`` -- write the room dir + config (+ optional prompt file) and
|
|
19
|
-
apply the ``room_paths`` edit; honors dry-run and force.
|
|
20
|
-
|
|
21
|
-
Pure filesystem work -- no Docker, no running backend, stdlib only.
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
from __future__ import annotations
|
|
25
|
-
|
|
26
|
-
import dataclasses
|
|
27
|
-
import pathlib
|
|
28
|
-
import re
|
|
29
|
-
|
|
30
|
-
# A room id usable as a path segment and a YAML id: no '/', no '..', no
|
|
31
|
-
# leading dot (mirrors rag_db.py's DB_NAME_RE).
|
|
32
|
-
ROOM_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
33
|
-
|
|
34
|
-
# Placeholder when the stack's own package can't be inferred (no 'src/<pkg>/');
|
|
35
|
-
# the '<pkg>.tools.greeting' demo tool in the skill templates references it.
|
|
36
|
-
DEFAULT_PACKAGE_NAME = "your_package"
|
|
37
|
-
# Written into the room dir when a prompt file is supplied; the config then
|
|
38
|
-
# points its system_prompt at this file (the 'search' demo room uses the form).
|
|
39
|
-
PROMPT_FILE_NAME = "prompt.txt"
|
|
40
|
-
|
|
41
|
-
# Stack markers: the files that mark a directory as a generated stack.
|
|
42
|
-
COMPOSE_FILE = "docker-compose.yml"
|
|
43
|
-
ENVIRONMENT_DIR = pathlib.PurePosixPath("backend", "environment")
|
|
44
|
-
INSTALLATION_FILE = ENVIRONMENT_DIR / "installation.yaml"
|
|
45
|
-
ROOMS_DIR = ENVIRONMENT_DIR / "rooms"
|
|
46
|
-
|
|
47
|
-
# room_paths splice: the anchor line and the entry-already-present probe.
|
|
48
|
-
_ROOM_PATHS_RE = re.compile(r"^room_paths:\s*$")
|
|
49
|
-
|
|
50
|
-
ADDED = "added"
|
|
51
|
-
UNCHANGED = "unchanged"
|
|
52
|
-
# room_paths may point at the rooms parent directory to auto-discover every
|
|
53
|
-
# room beneath it; when it does, a new room needs no room_paths entry.
|
|
54
|
-
ROOMS_PARENT_ENTRY = "./rooms"
|
|
55
|
-
COVERED = f'covered by "{ROOMS_PARENT_ENTRY}"'
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class AddRoomError(Exception):
|
|
59
|
-
"""A user-facing error (printed without a traceback).
|
|
60
|
-
|
|
61
|
-
Message construction lives in these classmethod factories so call sites
|
|
62
|
-
read ``raise AddRoomError.<reason>(...)`` with no inline message string.
|
|
63
|
-
"""
|
|
64
|
-
|
|
65
|
-
@classmethod
|
|
66
|
-
def compose_not_found(cls, path):
|
|
67
|
-
return cls(
|
|
68
|
-
f"no {COMPOSE_FILE} at {path} "
|
|
69
|
-
"(run with --project-dir pointing at the stack directory)"
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
@classmethod
|
|
73
|
-
def not_a_stack(cls, path):
|
|
74
|
-
return cls(
|
|
75
|
-
f"{path} is not a generated Soliplex stack: missing "
|
|
76
|
-
f"'{INSTALLATION_FILE}'"
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
@classmethod
|
|
80
|
-
def bad_room_id(cls, room_id):
|
|
81
|
-
return cls(
|
|
82
|
-
f"room id {room_id!r} must match {ROOM_ID_RE.pattern} "
|
|
83
|
-
"(letters, digits, '.', '_', '-'; no leading dot)"
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
@classmethod
|
|
87
|
-
def unknown_template(cls, name, available):
|
|
88
|
-
avail = ", ".join(sorted(available)) or "(none found)"
|
|
89
|
-
return cls(f"unknown template {name!r}; available: {avail}")
|
|
90
|
-
|
|
91
|
-
@classmethod
|
|
92
|
-
def prompt_file_missing(cls, path):
|
|
93
|
-
return cls(f"prompt file {path} does not exist")
|
|
94
|
-
|
|
95
|
-
@classmethod
|
|
96
|
-
def room_exists(cls, path):
|
|
97
|
-
return cls(f"{path} already exists (use force to overwrite it)")
|
|
98
|
-
|
|
99
|
-
@classmethod
|
|
100
|
-
def no_room_paths(cls, path):
|
|
101
|
-
return cls(
|
|
102
|
-
f"no 'room_paths:' block in {path} to extend "
|
|
103
|
-
"(unexpected installation.yaml shape)"
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def validate_room_id(room_id: str) -> None:
|
|
108
|
-
if not ROOM_ID_RE.match(room_id):
|
|
109
|
-
raise AddRoomError.bad_room_id(room_id)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def resolve_project(project_dir: str) -> pathlib.Path:
|
|
113
|
-
"""Return the resolved stack root, or raise if it is not a stack."""
|
|
114
|
-
project = pathlib.Path(project_dir).resolve()
|
|
115
|
-
if not (project / COMPOSE_FILE).is_file():
|
|
116
|
-
raise AddRoomError.compose_not_found(project / COMPOSE_FILE)
|
|
117
|
-
if not (project / INSTALLATION_FILE).is_file():
|
|
118
|
-
raise AddRoomError.not_a_stack(project)
|
|
119
|
-
return project
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def resolve_package_name(project: pathlib.Path, override: str | None) -> str:
|
|
123
|
-
"""The stack's own package (for ``<pkg>.tools.greeting``), or placeholder.
|
|
124
|
-
|
|
125
|
-
Prefer an explicit ``override``; otherwise infer the single package under
|
|
126
|
-
``src/`` (the generator scaffolds ``src/<package_name>/tools.py``); failing
|
|
127
|
-
that, return ``DEFAULT_PACKAGE_NAME`` for the operator to edit.
|
|
128
|
-
"""
|
|
129
|
-
if override is not None:
|
|
130
|
-
return override
|
|
131
|
-
src = project / "src"
|
|
132
|
-
if src.is_dir():
|
|
133
|
-
packages = [
|
|
134
|
-
child.name
|
|
135
|
-
for child in sorted(src.iterdir())
|
|
136
|
-
if child.is_dir() and (child / "tools.py").is_file()
|
|
137
|
-
]
|
|
138
|
-
if len(packages) == 1:
|
|
139
|
-
return packages[0]
|
|
140
|
-
return DEFAULT_PACKAGE_NAME
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def add_room_path(text: str, room_id: str) -> tuple[str, str]:
|
|
144
|
-
"""Ensure ``room_paths`` loads ``rooms/<room_id>``; return (text, action).
|
|
145
|
-
|
|
146
|
-
Action is ``"unchanged"`` when the explicit entry is already listed,
|
|
147
|
-
``COVERED`` when a ``./rooms`` entry already auto-discovers every room
|
|
148
|
-
beneath it (so no entry is needed), or ``"added"`` when the
|
|
149
|
-
``- "./rooms/<id>"`` entry is spliced in. The edit is line-based, so
|
|
150
|
-
comments and unrelated layout are preserved. Raises ``AddRoomError`` when
|
|
151
|
-
the file has no top-level ``room_paths:`` block.
|
|
152
|
-
"""
|
|
153
|
-
entry = f"{ROOMS_PARENT_ENTRY}/{room_id}"
|
|
154
|
-
probe = re.compile(r'-\s*["\']?' + re.escape(entry) + r'["\']?\s*$')
|
|
155
|
-
parent_probe = re.compile(
|
|
156
|
-
r'-\s*["\']?' + re.escape(ROOMS_PARENT_ENTRY) + r'/?["\']?\s*$'
|
|
157
|
-
)
|
|
158
|
-
lines = text.splitlines(keepends=True)
|
|
159
|
-
if any(probe.search(line) for line in lines):
|
|
160
|
-
return text, UNCHANGED
|
|
161
|
-
if any(parent_probe.search(line) for line in lines):
|
|
162
|
-
return text, COVERED
|
|
163
|
-
idx = next(
|
|
164
|
-
(i for i, line in enumerate(lines) if _ROOM_PATHS_RE.match(line)),
|
|
165
|
-
None,
|
|
166
|
-
)
|
|
167
|
-
if idx is None:
|
|
168
|
-
raise AddRoomError.no_room_paths(INSTALLATION_FILE)
|
|
169
|
-
lines.insert(idx + 1, f' - "{entry}"\n')
|
|
170
|
-
return "".join(lines), ADDED
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
@dataclasses.dataclass(frozen=True)
|
|
174
|
-
class RoomInstall:
|
|
175
|
-
"""The outcome of ``install_room``: where the config went + the room_paths
|
|
176
|
-
action (``added`` / ``COVERED`` / ``unchanged``)."""
|
|
177
|
-
|
|
178
|
-
config_path: pathlib.Path
|
|
179
|
-
path_action: str
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def install_room(
|
|
183
|
-
project: pathlib.Path,
|
|
184
|
-
room_id: str,
|
|
185
|
-
*,
|
|
186
|
-
config_text: str,
|
|
187
|
-
prompt_text: str | None = None,
|
|
188
|
-
force: bool = False,
|
|
189
|
-
dry_run: bool = False,
|
|
190
|
-
) -> RoomInstall:
|
|
191
|
-
"""Install a rendered room into ``project``; return a ``RoomInstall``.
|
|
192
|
-
|
|
193
|
-
Writes ``rooms/<room_id>/room_config.yaml`` (and ``prompt.txt`` when
|
|
194
|
-
``prompt_text`` is given), and ensures ``room_paths`` loads it. With
|
|
195
|
-
``dry_run`` it computes the outcome but writes nothing. Raises
|
|
196
|
-
``AddRoomError`` when the room directory already exists and ``force`` is
|
|
197
|
-
false. ``config_text`` is template-agnostic -- any caller-produced room
|
|
198
|
-
config.
|
|
199
|
-
"""
|
|
200
|
-
room_dir = project / ROOMS_DIR / room_id
|
|
201
|
-
config_path = room_dir / "room_config.yaml"
|
|
202
|
-
if room_dir.exists() and not force:
|
|
203
|
-
raise AddRoomError.room_exists(room_dir)
|
|
204
|
-
|
|
205
|
-
installation = project / INSTALLATION_FILE
|
|
206
|
-
new_installation, path_action = add_room_path(
|
|
207
|
-
installation.read_text(), room_id
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
if not dry_run:
|
|
211
|
-
room_dir.mkdir(parents=True, exist_ok=True)
|
|
212
|
-
config_path.write_text(config_text)
|
|
213
|
-
if prompt_text is not None:
|
|
214
|
-
(room_dir / PROMPT_FILE_NAME).write_text(prompt_text)
|
|
215
|
-
if path_action == ADDED:
|
|
216
|
-
installation.write_text(new_installation)
|
|
217
|
-
|
|
218
|
-
return RoomInstall(config_path=config_path, path_action=path_action)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|