gemba-cli 0.1.0__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.
- gemba/__init__.py +1 -0
- gemba/assets/harness/guide.md +88 -0
- gemba/assets/init/gemba.yaml +12 -0
- gemba/assets/skills/gemba-archive/SKILL.md +78 -0
- gemba/assets/skills/gemba-config/SKILL.md +176 -0
- gemba/assets/skills/gemba-create/SKILL.md +99 -0
- gemba/assets/skills/gemba-harness/SKILL.md +194 -0
- gemba/assets/skills/gemba-init/SKILL.md +81 -0
- gemba/assets/skills/gemba-list/SKILL.md +137 -0
- gemba/assets/skills/gemba-mark/SKILL.md +88 -0
- gemba/assets/skills/gemba-record/SKILL.md +89 -0
- gemba/assets/skills/gemba-report/SKILL.md +149 -0
- gemba/assets/skills/gemba-set/SKILL.md +104 -0
- gemba/assets/skills/gemba-worktree/SKILL.md +137 -0
- gemba/cli.py +35 -0
- gemba/commands/__init__.py +1 -0
- gemba/commands/archive.py +62 -0
- gemba/commands/config.py +117 -0
- gemba/commands/create.py +104 -0
- gemba/commands/harness.py +534 -0
- gemba/commands/init.py +112 -0
- gemba/commands/list.py +51 -0
- gemba/commands/mark.py +91 -0
- gemba/commands/record.py +113 -0
- gemba/commands/report.py +80 -0
- gemba/commands/selection.py +170 -0
- gemba/commands/set.py +81 -0
- gemba/commands/skills.py +102 -0
- gemba/commands/worktree.py +292 -0
- gemba/config.py +371 -0
- gemba/harness.py +401 -0
- gemba/metadata.py +442 -0
- gemba/repo.py +76 -0
- gemba/report.py +153 -0
- gemba/skills.py +199 -0
- gemba/tasks.py +621 -0
- gemba/values.py +16 -0
- gemba/worktree.py +191 -0
- gemba_cli-0.1.0.dist-info/METADATA +321 -0
- gemba_cli-0.1.0.dist-info/RECORD +43 -0
- gemba_cli-0.1.0.dist-info/WHEEL +4 -0
- gemba_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gemba_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
gemba/config.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Gemba config: discovery, loading and validation; field declarations are delegated to `metadata`."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from gemba import metadata, skills
|
|
11
|
+
from gemba.metadata import Field
|
|
12
|
+
from gemba.worktree import DEFAULT_BRANCH_PREFIX
|
|
13
|
+
|
|
14
|
+
CONFIG_FILENAME = "gemba.yaml"
|
|
15
|
+
|
|
16
|
+
WORKSTREAM_ID_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
17
|
+
PREFIX_PATTERN = re.compile(r"^[A-Z]+$")
|
|
18
|
+
# Processes whose block is validated, at the top level and in every workstream, each with the
|
|
19
|
+
# verbs of its group, or None for a process that is one verb; any other block is left to later sprints.
|
|
20
|
+
PROCESSES: dict[str, tuple[str, ...] | None] = {
|
|
21
|
+
"create": None,
|
|
22
|
+
"list": None,
|
|
23
|
+
"record": None,
|
|
24
|
+
"report": None,
|
|
25
|
+
"mark": None,
|
|
26
|
+
"archive": None,
|
|
27
|
+
"set": None,
|
|
28
|
+
"harness": ("launch", "note", "resolve", "remove"),
|
|
29
|
+
"worktree": ("add", "list", "remove"),
|
|
30
|
+
}
|
|
31
|
+
# Key of the harness block that is not a verb: the text launch renders into the guide.
|
|
32
|
+
GUIDE_KEY = "guide"
|
|
33
|
+
# Keys a group block takes besides its verbs; only the harness renders a guide.
|
|
34
|
+
GROUP_KEYS: dict[str, tuple[str, ...]] = {"harness": (GUIDE_KEY,)}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConfigError(Exception):
|
|
38
|
+
"""The gemba config is missing or invalid; nothing may be written."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Workstream:
|
|
43
|
+
id: str
|
|
44
|
+
prefix: str
|
|
45
|
+
description: str
|
|
46
|
+
metadata: tuple[Field, ...]
|
|
47
|
+
processes: dict[str, object]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Block:
|
|
52
|
+
"""The effective block of a process or of a verb of a group for one workstream, both levels combined."""
|
|
53
|
+
|
|
54
|
+
context: str | None
|
|
55
|
+
rules: tuple[str, ...]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Guide:
|
|
60
|
+
"""The two `harness.guide` texts of one workstream, as launch renders them under their own headings."""
|
|
61
|
+
|
|
62
|
+
organization: str | None
|
|
63
|
+
workstream: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Config:
|
|
68
|
+
root: Path
|
|
69
|
+
default_workstream: str
|
|
70
|
+
workstreams: dict[str, Workstream]
|
|
71
|
+
metadata: tuple[Field, ...]
|
|
72
|
+
agent_providers: tuple[str, ...] | None = None
|
|
73
|
+
ide: str | None = None
|
|
74
|
+
organization: str | None = None
|
|
75
|
+
repos_dir: str | None = None
|
|
76
|
+
branch_prefix: str = DEFAULT_BRANCH_PREFIX
|
|
77
|
+
# The base blocks: the top-level entries named after a process, shared by every workstream.
|
|
78
|
+
processes: dict[str, object] = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
def effective_schema(self, workstream_id: str) -> list[Field]:
|
|
81
|
+
"""Union by name of the base and workstream fields; the workstream declaration wins."""
|
|
82
|
+
return metadata.effective(self.metadata, self.workstreams[workstream_id].metadata)
|
|
83
|
+
|
|
84
|
+
def types(self) -> dict[str, str]:
|
|
85
|
+
"""Every field name declared anywhere in the config, base list first, mapped to its one type."""
|
|
86
|
+
return metadata.types(self.metadata, _lists(self.workstreams))
|
|
87
|
+
|
|
88
|
+
def effective_block(self, workstream_id: str, process: str, verb: str | None = None) -> Block:
|
|
89
|
+
"""The base block followed by the workstream's: both contexts one empty line apart, every base rule first.
|
|
90
|
+
|
|
91
|
+
`verb` names the verb of a group and must be None for a process that is one verb. A
|
|
92
|
+
context that is blank counts as not declared.
|
|
93
|
+
"""
|
|
94
|
+
verbs = PROCESSES[process]
|
|
95
|
+
if (verbs is None) != (verb is None) or (verbs is not None and verb not in verbs):
|
|
96
|
+
raise ValueError(f"'{process}' has no block for verb {verb!r}")
|
|
97
|
+
levels = [
|
|
98
|
+
_block(self.processes, process, verb),
|
|
99
|
+
_block(self.workstreams[workstream_id].processes, process, verb),
|
|
100
|
+
]
|
|
101
|
+
contexts = [text for level in levels if isinstance(text := level.get("context"), str) and text.strip()]
|
|
102
|
+
rules = [rule for level in levels for rule in _rules(level)]
|
|
103
|
+
return Block(context=_joined(contexts), rules=tuple(rules))
|
|
104
|
+
|
|
105
|
+
def effective_guide(self, workstream_id: str) -> Guide:
|
|
106
|
+
"""The base `harness.guide` and the workstream's, each None when its level declares none."""
|
|
107
|
+
return Guide(organization=guide(self.processes), workstream=guide(self.workstreams[workstream_id].processes))
|
|
108
|
+
|
|
109
|
+
def effective_processes(self, workstream_id: str) -> dict[str, object]:
|
|
110
|
+
"""Every process and every verb of a group with its effective block, and the harness guide, as plain data."""
|
|
111
|
+
entries: dict[str, object] = {}
|
|
112
|
+
for process, verbs in PROCESSES.items():
|
|
113
|
+
if verbs is None:
|
|
114
|
+
entries[process] = _plain(self.effective_block(workstream_id, process))
|
|
115
|
+
continue
|
|
116
|
+
entry: dict[str, object] = {}
|
|
117
|
+
if GUIDE_KEY in GROUP_KEYS.get(process, ()):
|
|
118
|
+
texts = self.effective_guide(workstream_id)
|
|
119
|
+
entry[GUIDE_KEY] = {"organization": texts.organization, "workstream": texts.workstream}
|
|
120
|
+
for verb in verbs:
|
|
121
|
+
entry[verb] = _plain(self.effective_block(workstream_id, process, verb))
|
|
122
|
+
entries[process] = entry
|
|
123
|
+
return entries
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _block(processes: Mapping[str, object], process: str, verb: str | None) -> Mapping[str, object]:
|
|
127
|
+
"""The block of a process, or of a verb of a group, at one level, empty when that level declares none."""
|
|
128
|
+
block = processes.get(process)
|
|
129
|
+
if verb is not None:
|
|
130
|
+
block = block.get(verb) if isinstance(block, dict) else None
|
|
131
|
+
return block if isinstance(block, dict) else {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _rules(block: Mapping[str, object]) -> list[str]:
|
|
135
|
+
rules = block.get("rules")
|
|
136
|
+
return [rule for rule in rules if isinstance(rule, str)] if isinstance(rules, list) else []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _joined(contexts: list[str]) -> str | None:
|
|
140
|
+
"""The contexts of both levels with one empty line between them, the one given alone, or None."""
|
|
141
|
+
if not contexts:
|
|
142
|
+
return None
|
|
143
|
+
if len(contexts) == 1:
|
|
144
|
+
return contexts[0]
|
|
145
|
+
base, own = contexts
|
|
146
|
+
return base.rstrip("\n") + "\n\n" + own.lstrip("\n")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _plain(block: Block) -> dict[str, object]:
|
|
150
|
+
return {"context": block.context, "rules": list(block.rules)}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def guide(processes: Mapping[str, object]) -> str | None:
|
|
154
|
+
"""The `harness.guide` text of one level, the top level or a workstream, or None when it declares none."""
|
|
155
|
+
block = processes.get("harness")
|
|
156
|
+
text = block.get(GUIDE_KEY) if isinstance(block, dict) else None
|
|
157
|
+
return text if isinstance(text, str) else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def find_root(start: Path | str) -> Path:
|
|
161
|
+
"""Return the nearest folder at or above `start` that contains the gemba config."""
|
|
162
|
+
start = Path(start).resolve()
|
|
163
|
+
for folder in (start, *start.parents):
|
|
164
|
+
if (folder / CONFIG_FILENAME).is_file():
|
|
165
|
+
return folder
|
|
166
|
+
raise ConfigError(f"no gemba config found: no {CONFIG_FILENAME} in {start} or any parent folder")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def load(root: Path | str | None = None, cwd: Path | str | None = None) -> Config:
|
|
170
|
+
"""Load and validate the config at `root`, or discover it by walking up from `cwd`."""
|
|
171
|
+
if root is None:
|
|
172
|
+
root = find_root(cwd or Path.cwd())
|
|
173
|
+
else:
|
|
174
|
+
root = Path(root).resolve()
|
|
175
|
+
if not (root / CONFIG_FILENAME).is_file():
|
|
176
|
+
raise ConfigError(f"no gemba config found: no {CONFIG_FILENAME} in {root}")
|
|
177
|
+
|
|
178
|
+
path = root / CONFIG_FILENAME
|
|
179
|
+
try:
|
|
180
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
181
|
+
except yaml.YAMLError as exc:
|
|
182
|
+
raise ConfigError(f"{path}: malformed YAML: {exc}") from exc
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
return _parse(root, data)
|
|
186
|
+
except ConfigError as exc:
|
|
187
|
+
raise ConfigError(f"invalid gemba config {path}: {exc}") from None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parse(root: Path, data: object) -> Config:
|
|
191
|
+
if not isinstance(data, dict):
|
|
192
|
+
raise ConfigError("the file must be a map with default_workstream and workstreams")
|
|
193
|
+
|
|
194
|
+
base = _parse_fields(data.get("metadata"), metadata.BASE_LIST)
|
|
195
|
+
_check_processes(data, "top level")
|
|
196
|
+
|
|
197
|
+
catalogue = data.get("workstreams")
|
|
198
|
+
if not isinstance(catalogue, dict) or not catalogue:
|
|
199
|
+
raise ConfigError("workstreams must be a non-empty map keyed by workstream identifier")
|
|
200
|
+
workstreams = {}
|
|
201
|
+
owners = {}
|
|
202
|
+
for workstream_id, block in catalogue.items():
|
|
203
|
+
workstream = _parse_workstream(workstream_id, block)
|
|
204
|
+
if workstream.prefix in owners:
|
|
205
|
+
raise ConfigError(
|
|
206
|
+
f"workstreams '{owners[workstream.prefix]}' and '{workstream_id}' "
|
|
207
|
+
f"declare the same prefix '{workstream.prefix}'"
|
|
208
|
+
)
|
|
209
|
+
owners[workstream.prefix] = workstream_id
|
|
210
|
+
workstreams[workstream_id] = workstream
|
|
211
|
+
|
|
212
|
+
default = data.get("default_workstream")
|
|
213
|
+
if default is None:
|
|
214
|
+
raise ConfigError("default_workstream is missing")
|
|
215
|
+
if default not in workstreams:
|
|
216
|
+
raise ConfigError(
|
|
217
|
+
f"default_workstream '{default}' is not in the workstream catalogue (valid: {', '.join(workstreams)})"
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
metadata.types(base, _lists(workstreams))
|
|
221
|
+
except metadata.MetadataError as exc:
|
|
222
|
+
raise ConfigError(str(exc)) from None
|
|
223
|
+
|
|
224
|
+
return Config(
|
|
225
|
+
root=root,
|
|
226
|
+
default_workstream=default,
|
|
227
|
+
workstreams=workstreams,
|
|
228
|
+
metadata=base,
|
|
229
|
+
agent_providers=_parse_providers(data.get("agent_providers")),
|
|
230
|
+
ide=_parse_ide(data.get("ide")),
|
|
231
|
+
organization=_parse_name("organization", data.get("organization")),
|
|
232
|
+
repos_dir=_parse_repos_dir(data.get("repos_dir")),
|
|
233
|
+
branch_prefix=_parse_name("branch_prefix", data.get("branch_prefix")) or DEFAULT_BRANCH_PREFIX,
|
|
234
|
+
processes={key: value for key, value in data.items() if key in PROCESSES},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _parse_name(key: str, declaration: object) -> str | None:
|
|
239
|
+
"""A kebab-case name that becomes a segment of a branch, or None when the config declares none."""
|
|
240
|
+
if declaration is None:
|
|
241
|
+
return None
|
|
242
|
+
if not isinstance(declaration, str) or not WORKSTREAM_ID_PATTERN.match(declaration):
|
|
243
|
+
raise ConfigError(f"{key} must be kebab-case (lowercase letters, digits and single hyphens)")
|
|
244
|
+
return declaration
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parse_repos_dir(declaration: object) -> str | None:
|
|
248
|
+
"""The folder of the repositories a run may check out, as declared, or None when the config declares none."""
|
|
249
|
+
if declaration is None:
|
|
250
|
+
return None
|
|
251
|
+
if not isinstance(declaration, str) or not declaration.strip():
|
|
252
|
+
raise ConfigError(
|
|
253
|
+
"repos_dir must be a non-empty string naming the folder of the repositories: "
|
|
254
|
+
"absolute, starting with ~, or relative to the tasks repo root"
|
|
255
|
+
)
|
|
256
|
+
return declaration
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _parse_ide(declaration: object) -> str | None:
|
|
260
|
+
"""The command that opens a folder in the IDE, or None when the config declares none."""
|
|
261
|
+
if declaration is None:
|
|
262
|
+
return None
|
|
263
|
+
if not isinstance(declaration, str) or not declaration.strip():
|
|
264
|
+
raise ConfigError("ide must be a non-empty string holding the command that opens a folder in the IDE")
|
|
265
|
+
return declaration
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _parse_providers(declaration: object) -> tuple[str, ...] | None:
|
|
269
|
+
"""The declared agent providers in order, or None when the config declares none."""
|
|
270
|
+
if declaration is None:
|
|
271
|
+
return None
|
|
272
|
+
known = f"(known providers: {', '.join(skills.PROVIDERS)})"
|
|
273
|
+
if not isinstance(declaration, list) or not declaration or not all(isinstance(item, str) for item in declaration):
|
|
274
|
+
raise ConfigError(f"agent_providers must be a non-empty list of provider identifiers {known}")
|
|
275
|
+
seen = set()
|
|
276
|
+
for provider in declaration:
|
|
277
|
+
if provider not in skills.PROVIDERS:
|
|
278
|
+
raise ConfigError(f"agent_providers: unknown provider '{provider}' {known}")
|
|
279
|
+
if provider in seen:
|
|
280
|
+
raise ConfigError(f"agent_providers: provider '{provider}' is listed more than once {known}")
|
|
281
|
+
seen.add(provider)
|
|
282
|
+
return tuple(declaration)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_workstream(workstream_id: object, block: object) -> Workstream:
|
|
286
|
+
where = f"workstream '{workstream_id}'"
|
|
287
|
+
if not isinstance(workstream_id, str) or not WORKSTREAM_ID_PATTERN.match(workstream_id):
|
|
288
|
+
raise ConfigError(f"{where}: identifier must be kebab-case (lowercase letters, digits and single hyphens)")
|
|
289
|
+
if not isinstance(block, dict):
|
|
290
|
+
raise ConfigError(f"{where}: must be a map with prefix and description")
|
|
291
|
+
|
|
292
|
+
prefix = block.get("prefix")
|
|
293
|
+
if not isinstance(prefix, str) or not PREFIX_PATTERN.match(prefix):
|
|
294
|
+
raise ConfigError(
|
|
295
|
+
f"{where}: prefix must be one or more uppercase ASCII letters (got {prefix!r}; "
|
|
296
|
+
"quote it if YAML reads it as another type)"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
description = block.get("description")
|
|
300
|
+
if not isinstance(description, str) or not description.strip():
|
|
301
|
+
raise ConfigError(f"{where}: description is missing or empty")
|
|
302
|
+
|
|
303
|
+
fields = _parse_fields(block.get("metadata"), _list_name(workstream_id))
|
|
304
|
+
_check_processes(block, where)
|
|
305
|
+
|
|
306
|
+
processes = {key: value for key, value in block.items() if key not in ("prefix", "description", "metadata")}
|
|
307
|
+
return Workstream(
|
|
308
|
+
id=workstream_id,
|
|
309
|
+
prefix=prefix,
|
|
310
|
+
description=description.strip(),
|
|
311
|
+
metadata=fields,
|
|
312
|
+
processes=processes,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _parse_fields(declarations: object, where: str) -> tuple[Field, ...]:
|
|
317
|
+
try:
|
|
318
|
+
return metadata.parse_fields(declarations, where)
|
|
319
|
+
except metadata.MetadataError as exc:
|
|
320
|
+
raise ConfigError(str(exc)) from None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _list_name(workstream_id: str) -> str:
|
|
324
|
+
"""How a refusal names the metadata list of a workstream."""
|
|
325
|
+
return f"workstream '{workstream_id}' metadata"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _lists(workstreams: dict[str, Workstream]) -> dict[str, tuple[Field, ...]]:
|
|
329
|
+
"""The metadata list of every workstream, keyed by the name a refusal gives it."""
|
|
330
|
+
return {_list_name(workstream.id): workstream.metadata for workstream in workstreams.values()}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _check_processes(block: dict[object, object], where: str) -> None:
|
|
334
|
+
"""Every process block of one level, the top level or a workstream, each validated by its shape."""
|
|
335
|
+
for process, verbs in PROCESSES.items():
|
|
336
|
+
if verbs is None:
|
|
337
|
+
_check_process_block(process, block.get(process), where)
|
|
338
|
+
else:
|
|
339
|
+
_check_group_block(process, verbs, GROUP_KEYS.get(process, ()), block.get(process), where)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _check_group_block(group: str, verbs: tuple[str, ...], extra: tuple[str, ...], block: object, where: str) -> None:
|
|
343
|
+
"""A group block holds one verb block per verb and the `extra` keys it allows; any other key is refused."""
|
|
344
|
+
if block is None:
|
|
345
|
+
return
|
|
346
|
+
allowed = (*extra, *verbs)
|
|
347
|
+
if not isinstance(block, dict):
|
|
348
|
+
raise ConfigError(f"{where}: {group} must be a map (allowed keys: {', '.join(allowed)})")
|
|
349
|
+
for key, value in block.items():
|
|
350
|
+
if key == GUIDE_KEY and key in extra:
|
|
351
|
+
if not isinstance(value, str) or not value.strip():
|
|
352
|
+
raise ConfigError(f"{where}: {group}.{GUIDE_KEY} must be a non-empty string")
|
|
353
|
+
elif key in verbs:
|
|
354
|
+
_check_process_block(f"{group}.{key}", value, where)
|
|
355
|
+
else:
|
|
356
|
+
raise ConfigError(f"{where}: {group}.{key} is not a key of {group} (allowed: {', '.join(allowed)})")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _check_process_block(process: str, block: object, where: str) -> None:
|
|
360
|
+
if block is None:
|
|
361
|
+
return
|
|
362
|
+
if not isinstance(block, dict):
|
|
363
|
+
raise ConfigError(f"{where}: {process} must be a map with rules and context")
|
|
364
|
+
rules = block.get("rules")
|
|
365
|
+
if rules is not None and (
|
|
366
|
+
not isinstance(rules, list) or not all(isinstance(rule, str) and rule.strip() for rule in rules)
|
|
367
|
+
):
|
|
368
|
+
raise ConfigError(f"{where}: {process}.rules must be a list of non-empty strings")
|
|
369
|
+
context = block.get("context")
|
|
370
|
+
if context is not None and not isinstance(context, str):
|
|
371
|
+
raise ConfigError(f"{where}: {process}.context must be a string")
|