soliplex-plumber 0.1__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.
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Generic, template-agnostic logic for adding a room to a Soliplex stack.
|
|
2
|
+
|
|
3
|
+
This is the shared core behind two consumers: the ``soliplex-template`` skill's
|
|
4
|
+
bundled ``add_room.py`` (a PEP 723 shim that owns the ``.mako`` templates and
|
|
5
|
+
the CLI) and the ``soliplex-concierge`` installer. Each delegates the
|
|
6
|
+
stack-level work here so the room-wiring rules live in one place. Any consumer
|
|
7
|
+
can ``from soliplex_plumber.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 room_exists(cls, path):
|
|
88
|
+
return cls(f"{path} already exists (use force to overwrite it)")
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def no_room_paths(cls, path):
|
|
92
|
+
return cls(
|
|
93
|
+
f"no 'room_paths:' block in {path} to extend "
|
|
94
|
+
"(unexpected installation.yaml shape)"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_room_id(room_id: str) -> None:
|
|
99
|
+
if not ROOM_ID_RE.match(room_id):
|
|
100
|
+
raise AddRoomError.bad_room_id(room_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve_project(project_dir: str) -> pathlib.Path:
|
|
104
|
+
"""Return the resolved stack root, or raise if it is not a stack."""
|
|
105
|
+
project = pathlib.Path(project_dir).resolve()
|
|
106
|
+
if not (project / COMPOSE_FILE).is_file():
|
|
107
|
+
raise AddRoomError.compose_not_found(project / COMPOSE_FILE)
|
|
108
|
+
if not (project / INSTALLATION_FILE).is_file():
|
|
109
|
+
raise AddRoomError.not_a_stack(project)
|
|
110
|
+
return project
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_package_name(project: pathlib.Path, override: str | None) -> str:
|
|
114
|
+
"""The stack's own package (for ``<pkg>.tools.greeting``), or placeholder.
|
|
115
|
+
|
|
116
|
+
Prefer an explicit ``override``; otherwise infer the single package under
|
|
117
|
+
``src/`` (the generator scaffolds ``src/<package_name>/tools.py``); failing
|
|
118
|
+
that, return ``DEFAULT_PACKAGE_NAME`` for the operator to edit.
|
|
119
|
+
"""
|
|
120
|
+
if override is not None:
|
|
121
|
+
return override
|
|
122
|
+
src = project / "src"
|
|
123
|
+
if src.is_dir():
|
|
124
|
+
packages = [
|
|
125
|
+
child.name
|
|
126
|
+
for child in sorted(src.iterdir())
|
|
127
|
+
if child.is_dir() and (child / "tools.py").is_file()
|
|
128
|
+
]
|
|
129
|
+
if len(packages) == 1:
|
|
130
|
+
return packages[0]
|
|
131
|
+
return DEFAULT_PACKAGE_NAME
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def add_room_path(text: str, room_id: str) -> tuple[str, str]:
|
|
135
|
+
"""Ensure ``room_paths`` loads ``rooms/<room_id>``; return (text, action).
|
|
136
|
+
|
|
137
|
+
Action is ``"unchanged"`` when the explicit entry is already listed,
|
|
138
|
+
``COVERED`` when a ``./rooms`` entry already auto-discovers every room
|
|
139
|
+
beneath it (so no entry is needed), or ``"added"`` when the
|
|
140
|
+
``- "./rooms/<id>"`` entry is spliced in. The edit is line-based, so
|
|
141
|
+
comments and unrelated layout are preserved. Raises ``AddRoomError`` when
|
|
142
|
+
the file has no top-level ``room_paths:`` block.
|
|
143
|
+
"""
|
|
144
|
+
entry = f"{ROOMS_PARENT_ENTRY}/{room_id}"
|
|
145
|
+
probe = re.compile(r'-\s*["\']?' + re.escape(entry) + r'["\']?\s*$')
|
|
146
|
+
parent_probe = re.compile(
|
|
147
|
+
r'-\s*["\']?' + re.escape(ROOMS_PARENT_ENTRY) + r'/?["\']?\s*$'
|
|
148
|
+
)
|
|
149
|
+
lines = text.splitlines(keepends=True)
|
|
150
|
+
if any(probe.search(line) for line in lines):
|
|
151
|
+
return text, UNCHANGED
|
|
152
|
+
if any(parent_probe.search(line) for line in lines):
|
|
153
|
+
return text, COVERED
|
|
154
|
+
idx = next(
|
|
155
|
+
(i for i, line in enumerate(lines) if _ROOM_PATHS_RE.match(line)),
|
|
156
|
+
None,
|
|
157
|
+
)
|
|
158
|
+
if idx is None:
|
|
159
|
+
raise AddRoomError.no_room_paths(INSTALLATION_FILE)
|
|
160
|
+
lines.insert(idx + 1, f' - "{entry}"\n')
|
|
161
|
+
return "".join(lines), ADDED
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclasses.dataclass(frozen=True)
|
|
165
|
+
class RoomInstalled:
|
|
166
|
+
"""The outcome of ``install_room``: where the config went + the room_paths
|
|
167
|
+
action (``added`` / ``COVERED`` / ``unchanged``)."""
|
|
168
|
+
|
|
169
|
+
config_path: pathlib.Path
|
|
170
|
+
path_action: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Backward-compatible alias for the pre-rename name.
|
|
174
|
+
RoomInstall = RoomInstalled
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def install_room(
|
|
178
|
+
project: pathlib.Path,
|
|
179
|
+
room_id: str,
|
|
180
|
+
*,
|
|
181
|
+
config_text: str,
|
|
182
|
+
prompt_text: str | None = None,
|
|
183
|
+
force: bool = False,
|
|
184
|
+
dry_run: bool = False,
|
|
185
|
+
) -> RoomInstalled:
|
|
186
|
+
"""Install a rendered room into ``project``; return a ``RoomInstalled``.
|
|
187
|
+
|
|
188
|
+
Writes ``rooms/<room_id>/room_config.yaml`` (and ``prompt.txt`` when
|
|
189
|
+
``prompt_text`` is given), and ensures ``room_paths`` loads it. With
|
|
190
|
+
``dry_run`` it computes the outcome but writes nothing. Raises
|
|
191
|
+
``AddRoomError`` when the room directory already exists and ``force`` is
|
|
192
|
+
false. ``config_text`` is template-agnostic -- any caller-produced room
|
|
193
|
+
config.
|
|
194
|
+
"""
|
|
195
|
+
room_dir = project / ROOMS_DIR / room_id
|
|
196
|
+
config_path = room_dir / "room_config.yaml"
|
|
197
|
+
if room_dir.exists() and not force:
|
|
198
|
+
raise AddRoomError.room_exists(room_dir)
|
|
199
|
+
|
|
200
|
+
installation = project / INSTALLATION_FILE
|
|
201
|
+
new_installation, path_action = add_room_path(
|
|
202
|
+
installation.read_text(), room_id
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if not dry_run:
|
|
206
|
+
room_dir.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
config_path.write_text(config_text)
|
|
208
|
+
if prompt_text is not None:
|
|
209
|
+
(room_dir / PROMPT_FILE_NAME).write_text(prompt_text)
|
|
210
|
+
if path_action == ADDED:
|
|
211
|
+
installation.write_text(new_installation)
|
|
212
|
+
|
|
213
|
+
return RoomInstalled(config_path=config_path, path_action=path_action)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: soliplex-plumber
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: Read and modify the configuration of an existing Soliplex stack.
|
|
5
|
+
Project-URL: Homepage, https://soliplex.github.io/soliplex-plumber/
|
|
6
|
+
Project-URL: Repository, https://github.com/soliplex/soliplex-plumber
|
|
7
|
+
Author: Soliplex
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: configuration,installation,rooms,soliplex,stack
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# `soliplex-plumber`: tools for modifying a Soliplex stack
|
|
15
|
+
|
|
16
|
+
Stdlib-only library for reading and modifying the configuration of an
|
|
17
|
+
*existing* Soliplex stack. It is the shared dependency for the skill projects
|
|
18
|
+
that operate on a generated stack — the `soliplex-template` skill's
|
|
19
|
+
`add_room.py` and the `soliplex-concierge` installer — so the stack-wiring
|
|
20
|
+
rules live in one place.
|
|
21
|
+
|
|
22
|
+
It does pure filesystem work: no Docker, no running backend.
|
|
23
|
+
|
|
24
|
+
## What it provides
|
|
25
|
+
|
|
26
|
+
- **`rooms`** — generic, template-agnostic logic for adding a room to a stack:
|
|
27
|
+
resolve and validate the stack root, infer its package, and ensure
|
|
28
|
+
`installation.yaml`'s `room_paths` loads the room (editing line-based, so
|
|
29
|
+
comments and layout are preserved).
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from soliplex_plumber import rooms
|
|
33
|
+
|
|
34
|
+
project = rooms.resolve_project("/path/to/stack")
|
|
35
|
+
installed = rooms.install_room(project, "handbook", config_text=cfg)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Full documentation: <https://soliplex.github.io/soliplex-plumber/>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
soliplex_plumber/rooms.py,sha256=3yJn1Fx1hO1-vSKwo_2buvTx_dzo0W8-LFmDoH8SIQM,7950
|
|
2
|
+
soliplex_plumber-0.1.dist-info/METADATA,sha256=ap3yCqgstJqNUwl04ZK5EnKOLDEaedInlPy4I42qs4M,1422
|
|
3
|
+
soliplex_plumber-0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
soliplex_plumber-0.1.dist-info/licenses/LICENSE,sha256=pQGCrNy-iPha4jIhHdzhY0iJO23LUUymDinb1YxlX-I,1065
|
|
5
|
+
soliplex_plumber-0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Soliplex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|