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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soliplex-template
3
- Version: 0.12.1
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.12.1"
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
- # Direct runtime dep of generate_soliplex_project.py (read_properties);
38
- # pinned to match that script's PEP 723 header.
39
- "skills-ref==0.1.1",
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 the
45
- # importable 'soliplex_template' package (src/), so it builds as a wheel. PEP 420
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
- # Hermetic unit tests for the repo's Python scripts. The suite holds 100% branch
53
- # coverage; --cov=scripts measures the repo-level build tooling, --cov=skill/scripts
54
- # the bundled generator/version helpers, and --cov=tests/unit keeps the test
55
- # files themselves dead-code-free.
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
- # Lint/format config mirrors soliplex/soliplex (same rule selection, line length,
73
- # single-line imports, and pytest-style tweaks). ruff-format is enforced by the
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
- @classmethod
67
- def not_ready(cls, url):
68
- return cls(
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
- @classmethod
74
- def reserved_user(cls, name):
75
- return cls(
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
- @classmethod
85
- def token_request_failed(cls, code, body):
86
- return cls(f"token request failed: HTTP {code}: {body}")
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
- @classmethod
89
- def no_token(cls, body):
90
- return cls(f"could not parse token from response: {body}")
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
- @classmethod
97
- def not_a_git_repo(cls, path):
98
- return cls(
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
- @classmethod
104
- def no_ssh_keys(cls):
105
- return cls(
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
- @classmethod
116
- def push_failed(cls, stderr):
117
- return cls(f"git push to Gitea failed: {stderr}")
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 GiteaError.no_token(body) from exc
192
+ raise NoToken(body) from exc
173
193
  token = data.get("sha1")
174
194
  if not token:
175
- raise GiteaError.no_token(body)
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 GiteaError.token_request_failed(exc.code, body) from exc
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 GiteaError.repo_failed(code)
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 GiteaError.key_upload_failed(exc.code, body) from exc
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 GiteaError.push_failed(result.stderr.strip())
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 GiteaError.not_a_git_repo(project)
415
+ raise NotAGitRepo(project)
396
416
  keys = discover_ssh_keys(ssh_key=ssh_key)
397
417
  if not keys:
398
- raise GiteaError.no_ssh_keys()
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 GiteaError.reserved_user(webui_user)
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 GiteaError.not_ready(GITEA_HTTP)
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
- @classmethod
53
- def compose_not_found(cls, path):
54
- return cls(
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 SecretsError.compose_not_found(compose)
166
+ raise ComposeNotFound(compose)
165
167
 
166
168
  secrets_dir = project / SECRETS_DIR
167
169
  secrets_dir.mkdir(parents=True, exist_ok=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soliplex-template
3
- Version: 0.12.1
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/),
@@ -2,7 +2,6 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/soliplex_template/gitea.py
5
- src/soliplex_template/rooms.py
6
5
  src/soliplex_template/secrets.py
7
6
  src/soliplex_template.egg-info/PKG-INFO
8
7
  src/soliplex_template.egg-info/SOURCES.txt
@@ -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)