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/metadata.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Metadata rules: field declarations and their types, the effective schema and values.
|
|
2
|
+
|
|
3
|
+
The type of a field tells every process how to read, write, compare and operate on a
|
|
4
|
+
value of it; what the field means belongs to the person who declared it. This module
|
|
5
|
+
knows nothing of task files, statuses, built-in dates or folders.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import datetime
|
|
10
|
+
import decimal
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from gemba import values
|
|
19
|
+
|
|
20
|
+
TEXT = "text"
|
|
21
|
+
NUMBER = "number"
|
|
22
|
+
BOOLEAN = "boolean"
|
|
23
|
+
DATE = "date"
|
|
24
|
+
TYPES = (TEXT, NUMBER, BOOLEAN, DATE)
|
|
25
|
+
# Names every task is grouped by in a report, so no field may take them.
|
|
26
|
+
RESERVED = ("workstream", "status")
|
|
27
|
+
|
|
28
|
+
FIELD_NAME_PATTERN = re.compile(r"^[a-z0-9]+(_[a-z0-9]+)*$")
|
|
29
|
+
FIELD_KEYS = ("name", "type", "required", "description")
|
|
30
|
+
# The key of the base list in the gemba config, which names it in a refusal.
|
|
31
|
+
BASE_LIST = "metadata"
|
|
32
|
+
# How a value of each type is typed on the command line; `null` clears a value of any type.
|
|
33
|
+
FORMS = {TEXT: "any text", NUMBER: "a decimal such as 3, 2.5 or -1", BOOLEAN: "true or false", DATE: "YYYY-MM-DD"}
|
|
34
|
+
NUMBER_PATTERN = re.compile(r"^-?(0|[1-9][0-9]*)(\.[0-9]+)?$")
|
|
35
|
+
NULL = "null"
|
|
36
|
+
SET = "="
|
|
37
|
+
ADD = "+="
|
|
38
|
+
# The types whose values are ordered, so that a selection may bound them.
|
|
39
|
+
BOUNDED = (NUMBER, DATE)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MetadataError(Exception):
|
|
43
|
+
"""A declaration or a value breaks a metadata rule; the message names the field and the rule."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Field:
|
|
48
|
+
name: str
|
|
49
|
+
type: str
|
|
50
|
+
required: bool = False
|
|
51
|
+
description: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Assignment:
|
|
56
|
+
"""One assignment typed on the command line, its value as written."""
|
|
57
|
+
|
|
58
|
+
key: str
|
|
59
|
+
operator: str
|
|
60
|
+
text: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Change:
|
|
65
|
+
"""What the assignments did to one key, or a key of the schema the map lacked, added as null."""
|
|
66
|
+
|
|
67
|
+
key: str
|
|
68
|
+
old: object
|
|
69
|
+
new: object
|
|
70
|
+
added: bool = False
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def changed(self) -> bool:
|
|
74
|
+
return self.added or show(self.old) != show(self.new)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Assigned:
|
|
79
|
+
"""The metadata map to write, what changed in it, and every problem that forbids writing it."""
|
|
80
|
+
|
|
81
|
+
values: dict[str, object]
|
|
82
|
+
changes: list[Change]
|
|
83
|
+
problems: list[str]
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def changed(self) -> bool:
|
|
87
|
+
return any(change.changed for change in self.changes)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Conditions:
|
|
92
|
+
"""Metadata conditions of a selection, each value converted by the type of its key.
|
|
93
|
+
|
|
94
|
+
The equalities of one key are alternatives; every other condition must hold. Bounds
|
|
95
|
+
are inclusive. `types` holds the declared type of every key with a condition.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
equalities: Mapping[str, Sequence[object]] = dataclasses.field(default_factory=dict)
|
|
99
|
+
lower: Mapping[str, object] = dataclasses.field(default_factory=dict)
|
|
100
|
+
upper: Mapping[str, object] = dataclasses.field(default_factory=dict)
|
|
101
|
+
types: Mapping[str, str] = dataclasses.field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
def matches(self, values: Mapping[str, object], declared: Collection[str]) -> bool:
|
|
104
|
+
"""Whether a task with these metadata `values` meets every condition.
|
|
105
|
+
|
|
106
|
+
`declared` holds the keys of the effective schema of the task's workstream: a key
|
|
107
|
+
out of it never matches, and a declared key the task lacks reads as null. A value
|
|
108
|
+
that does not match the type of its key never matches.
|
|
109
|
+
"""
|
|
110
|
+
for key in self.types:
|
|
111
|
+
if key not in declared:
|
|
112
|
+
return False
|
|
113
|
+
value = values.get(key)
|
|
114
|
+
if value_problem(self.types[key], value) is not None:
|
|
115
|
+
return False
|
|
116
|
+
if key in self.equalities:
|
|
117
|
+
candidates = value if isinstance(value, list) else [value]
|
|
118
|
+
if not any(_same(candidate, wanted) for candidate in candidates for wanted in self.equalities[key]):
|
|
119
|
+
return False
|
|
120
|
+
if key in self.lower or key in self.upper:
|
|
121
|
+
if value is None:
|
|
122
|
+
return False
|
|
123
|
+
ordered: Any = value # a number or a date, as value_problem checked
|
|
124
|
+
if key in self.lower and ordered < self.lower[key] or key in self.upper and ordered > self.upper[key]:
|
|
125
|
+
return False
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_fields(declarations: object, where: str) -> tuple[Field, ...]:
|
|
130
|
+
"""The fields of one declaration list, `where` naming the list in a refusal."""
|
|
131
|
+
if declarations is None:
|
|
132
|
+
return ()
|
|
133
|
+
if not isinstance(declarations, list):
|
|
134
|
+
raise MetadataError(f"{where}: must be a list of field declarations")
|
|
135
|
+
|
|
136
|
+
fields = []
|
|
137
|
+
seen = set()
|
|
138
|
+
for index, declaration in enumerate(declarations):
|
|
139
|
+
at = f"{where}[{index}]"
|
|
140
|
+
if not isinstance(declaration, dict):
|
|
141
|
+
raise MetadataError(f"{at}: a field declaration must be a map with at least a name and a type")
|
|
142
|
+
name = declaration.get("name")
|
|
143
|
+
if name is None:
|
|
144
|
+
raise MetadataError(f"{at}: field declaration has no name")
|
|
145
|
+
if not isinstance(name, str) or not FIELD_NAME_PATTERN.match(name):
|
|
146
|
+
raise MetadataError(
|
|
147
|
+
f"{at}: field name {name!r} must be snake_case (lowercase letters, digits and single underscores)"
|
|
148
|
+
)
|
|
149
|
+
if name in RESERVED:
|
|
150
|
+
raise MetadataError(
|
|
151
|
+
f"{at}: field name '{name}' is reserved: a report groups every task by its {name} "
|
|
152
|
+
f"(reserved: {', '.join(RESERVED)})"
|
|
153
|
+
)
|
|
154
|
+
unknown = [key for key in declaration if key not in FIELD_KEYS]
|
|
155
|
+
if unknown:
|
|
156
|
+
raise MetadataError(
|
|
157
|
+
f"{at}: field '{name}' has unsupported keys {', '.join(map(str, unknown))} "
|
|
158
|
+
f"(allowed: {', '.join(FIELD_KEYS)})"
|
|
159
|
+
)
|
|
160
|
+
kind = declaration.get("type")
|
|
161
|
+
if kind is None:
|
|
162
|
+
raise MetadataError(f"{at}: field '{name}' has no type (types: {', '.join(TYPES)})")
|
|
163
|
+
if kind not in TYPES:
|
|
164
|
+
shown = f"'{kind}'" if isinstance(kind, str) else repr(kind)
|
|
165
|
+
raise MetadataError(f"{at}: field '{name}' has unknown type {shown} (types: {', '.join(TYPES)})")
|
|
166
|
+
required = declaration.get("required", False)
|
|
167
|
+
if not isinstance(required, bool):
|
|
168
|
+
raise MetadataError(f"{at}: field '{name}' required must be true or false")
|
|
169
|
+
description = declaration.get("description")
|
|
170
|
+
if description is not None and (not isinstance(description, str) or not description.strip()):
|
|
171
|
+
raise MetadataError(f"{at}: field '{name}' description must be a non-empty string")
|
|
172
|
+
if name in seen:
|
|
173
|
+
raise MetadataError(f"{where}: field '{name}' is declared more than once")
|
|
174
|
+
seen.add(name)
|
|
175
|
+
fields.append(Field(name=name, type=kind, required=required, description=description))
|
|
176
|
+
return tuple(fields)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def effective(base: Sequence[Field], own: Sequence[Field]) -> list[Field]:
|
|
180
|
+
"""Union by name of the base fields and a workstream's own; the workstream declaration wins."""
|
|
181
|
+
overrides = {field.name: field for field in own}
|
|
182
|
+
schema = [overrides.pop(field.name, field) for field in base]
|
|
183
|
+
schema.extend(overrides.values())
|
|
184
|
+
return schema
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def types(base: Sequence[Field], lists: Mapping[str, Sequence[Field]]) -> dict[str, str]:
|
|
188
|
+
"""Every declared name, base list first, mapped to its one type.
|
|
189
|
+
|
|
190
|
+
`lists` maps each other declaration list, named as a refusal names it, to its
|
|
191
|
+
fields. A name declared with two types is refused, naming the two lists.
|
|
192
|
+
"""
|
|
193
|
+
found: dict[str, tuple[str, str]] = {}
|
|
194
|
+
for where, fields in [(BASE_LIST, base), *lists.items()]:
|
|
195
|
+
for field in fields:
|
|
196
|
+
first = found.setdefault(field.name, (field.type, where))
|
|
197
|
+
if first[0] != field.type:
|
|
198
|
+
raise MetadataError(
|
|
199
|
+
f"field '{field.name}' has type {first[0]} in {first[1]} and type {field.type} in {where}: "
|
|
200
|
+
"a field name has one type across the config"
|
|
201
|
+
)
|
|
202
|
+
return {name: kind for name, (kind, _where) in found.items()}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def parse_assignments(texts: Sequence[str], allow_add: bool) -> tuple[list[Assignment], list[str]]:
|
|
206
|
+
"""Split each `key=value` text, and `key+=value` when `allow_add`, the value kept as written.
|
|
207
|
+
|
|
208
|
+
No YAML is read.
|
|
209
|
+
"""
|
|
210
|
+
assignments = []
|
|
211
|
+
problems = []
|
|
212
|
+
usage = "use key=value or key+=number" if allow_add else "use key=value"
|
|
213
|
+
for text in texts:
|
|
214
|
+
key, separator, value = text.partition(SET)
|
|
215
|
+
operator = SET
|
|
216
|
+
if allow_add and key.endswith("+"):
|
|
217
|
+
key, operator = key[:-1], ADD
|
|
218
|
+
key = key.strip()
|
|
219
|
+
if not separator or not key or key.endswith("+"):
|
|
220
|
+
problems.append(f"invalid metadata assignment '{text}': {usage}")
|
|
221
|
+
continue
|
|
222
|
+
assignments.append(Assignment(key, operator, value))
|
|
223
|
+
return assignments, problems
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def convert(kind: str, text: str) -> object:
|
|
227
|
+
"""The value of a field of type `kind` typed as `text`, or None for `null`.
|
|
228
|
+
|
|
229
|
+
Text is kept as written, a number is a decimal literal, a boolean is `true` or
|
|
230
|
+
`false` and a date is ISO. A refusal reads after "metadata value of '<key>'".
|
|
231
|
+
"""
|
|
232
|
+
if text == NULL:
|
|
233
|
+
return None
|
|
234
|
+
if kind == TEXT:
|
|
235
|
+
return text
|
|
236
|
+
if kind == NUMBER and NUMBER_PATTERN.match(text):
|
|
237
|
+
return float(text) if "." in text else int(text)
|
|
238
|
+
if kind == BOOLEAN and text in ("true", "false"):
|
|
239
|
+
return text == "true"
|
|
240
|
+
if kind == DATE and (day := values.iso_date(text)) is not None:
|
|
241
|
+
return day
|
|
242
|
+
raise MetadataError(f"must be a {kind} ({FORMS[kind]}), not '{text}'")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def value_problem(kind: str, value: object) -> str | None:
|
|
246
|
+
"""Why a stored value is not of type `kind`, read after "metadata value of '<key>'", or None.
|
|
247
|
+
|
|
248
|
+
`null` is a value of every type, and only a text field holds a list, of strings.
|
|
249
|
+
"""
|
|
250
|
+
if value is None or _of_type(kind, value):
|
|
251
|
+
return None
|
|
252
|
+
if kind == TEXT and isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
253
|
+
return None
|
|
254
|
+
return f"must be {'text' if kind == TEXT else f'a {kind}'}, not {show(value)}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def shape_problem(value: object) -> str | None:
|
|
258
|
+
"""Why a stored value is not a scalar or a list of scalars, or None when it is."""
|
|
259
|
+
if _is_scalar(value) or isinstance(value, list) and all(_is_scalar(item) for item in value):
|
|
260
|
+
return None
|
|
261
|
+
return "must be a scalar or a list of scalars"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def add(current: object, amount: object) -> int | float:
|
|
265
|
+
"""`current` plus `amount`, two numbers or null, added exactly in decimal from their text.
|
|
266
|
+
|
|
267
|
+
Null counts as zero. A total with no fractional part is an int, any other a float
|
|
268
|
+
rebuilt from the exact decimal, so that 0.1 plus 0.2 is 0.3.
|
|
269
|
+
"""
|
|
270
|
+
total = _decimal(current) + _decimal(amount)
|
|
271
|
+
if total == total.to_integral_value():
|
|
272
|
+
return int(total)
|
|
273
|
+
return float(total)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def show(value: object) -> str:
|
|
277
|
+
"""A value as it reads in YAML flow style: acme, 'no', 3, 2026-10-03, null or [checkout, landing]."""
|
|
278
|
+
text = yaml.safe_dump(value, default_flow_style=True, allow_unicode=True, width=float("inf"))
|
|
279
|
+
return text.removesuffix("\n").removesuffix("\n...")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def assign(
|
|
283
|
+
schema: Sequence[Field], current: Mapping[str, object], assignments: Sequence[Assignment], where: str
|
|
284
|
+
) -> Assigned:
|
|
285
|
+
"""The metadata map after `assignments`, what changed in it, and every problem found.
|
|
286
|
+
|
|
287
|
+
The map holds every key of `schema` in its order, with its assigned value, its value
|
|
288
|
+
in `current` or null, followed by the keys of `current` that the schema does not
|
|
289
|
+
declare, as they were. `where` names the schema in a problem, such as "'support'".
|
|
290
|
+
"""
|
|
291
|
+
fields = {field.name: field for field in schema}
|
|
292
|
+
problems = []
|
|
293
|
+
undeclared = [key for key in dict.fromkeys(assignment.key for assignment in assignments) if key not in fields]
|
|
294
|
+
if undeclared:
|
|
295
|
+
declared = ", ".join(fields) if fields else "none"
|
|
296
|
+
problems.append(f"undeclared metadata keys for {where}: {', '.join(undeclared)} (declared: {declared})")
|
|
297
|
+
|
|
298
|
+
given: dict[str, list[Assignment]] = {}
|
|
299
|
+
for assignment in assignments:
|
|
300
|
+
if assignment.key in fields:
|
|
301
|
+
given.setdefault(assignment.key, []).append(assignment)
|
|
302
|
+
assigned: dict[str, object] = {}
|
|
303
|
+
for key, group in given.items():
|
|
304
|
+
try:
|
|
305
|
+
assigned[key] = _assigned_value(fields[key], current.get(key), group)
|
|
306
|
+
except MetadataError as exc:
|
|
307
|
+
problems.append(str(exc))
|
|
308
|
+
|
|
309
|
+
written = {name: assigned[name] if name in assigned else current.get(name) for name in fields}
|
|
310
|
+
written.update((key, value) for key, value in current.items() if key not in fields)
|
|
311
|
+
# A key whose assignment was refused is reported once, for that refusal.
|
|
312
|
+
missing = [
|
|
313
|
+
field.name
|
|
314
|
+
for field in schema
|
|
315
|
+
if field.required and written[field.name] is None and (field.name in assigned or field.name not in given)
|
|
316
|
+
]
|
|
317
|
+
if missing:
|
|
318
|
+
problems.append(f"missing required metadata keys for {where}: {', '.join(missing)}")
|
|
319
|
+
|
|
320
|
+
changes = [Change(key, current.get(key), assigned[key]) for key in given if key in assigned]
|
|
321
|
+
changes += [Change(name, None, None, added=True) for name in fields if name not in current and name not in given]
|
|
322
|
+
return Assigned(written, changes, problems)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _assigned_value(field: Field, old: object, group: Sequence[Assignment]) -> object:
|
|
326
|
+
"""The value the assignments of one declared key give it, `old` being its value before them.
|
|
327
|
+
|
|
328
|
+
A key repeated with `=` gives a text list; `+=` adds a number to `old`, null counting
|
|
329
|
+
as zero.
|
|
330
|
+
"""
|
|
331
|
+
key = field.name
|
|
332
|
+
if len({assignment.operator for assignment in group}) > 1:
|
|
333
|
+
raise MetadataError(f"metadata key '{key}' is given with both {SET} and {ADD}: give one or the other")
|
|
334
|
+
if len(group) > 1 and field.type != TEXT:
|
|
335
|
+
raise MetadataError(
|
|
336
|
+
f"metadata key '{key}' is given more than once, but only a text field holds a list "
|
|
337
|
+
f"('{key}' is a {field.type} field)"
|
|
338
|
+
)
|
|
339
|
+
if group[0].operator == ADD:
|
|
340
|
+
if field.type != NUMBER:
|
|
341
|
+
raise MetadataError(f"metadata key '{key}' is a {field.type} field: {ADD} adds to a number field only")
|
|
342
|
+
text = group[0].text
|
|
343
|
+
if text == NULL or not NUMBER_PATTERN.match(text):
|
|
344
|
+
raise MetadataError(f"metadata value of '{key}' must be a number ({FORMS[NUMBER]}), not '{text}'")
|
|
345
|
+
if value_problem(NUMBER, old) is not None:
|
|
346
|
+
raise MetadataError(
|
|
347
|
+
f"cannot add to '{key}': its value {show(old)} is not a number; set it with {key}=<number>"
|
|
348
|
+
)
|
|
349
|
+
return add(old, convert(NUMBER, text))
|
|
350
|
+
if len(group) == 1:
|
|
351
|
+
try:
|
|
352
|
+
return convert(field.type, group[0].text)
|
|
353
|
+
except MetadataError as exc:
|
|
354
|
+
raise MetadataError(f"metadata value of '{key}' {exc}") from None
|
|
355
|
+
items = [assignment.text for assignment in group]
|
|
356
|
+
if NULL in items:
|
|
357
|
+
raise MetadataError(f"metadata key '{key}' is given more than once with null, which clears the value alone")
|
|
358
|
+
return items
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _decimal(value: object) -> decimal.Decimal:
|
|
362
|
+
return decimal.Decimal(0) if value is None else decimal.Decimal(str(value))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _of_type(kind: str, value: object) -> bool:
|
|
366
|
+
if kind == TEXT:
|
|
367
|
+
return isinstance(value, str)
|
|
368
|
+
if kind == NUMBER:
|
|
369
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
370
|
+
if kind == BOOLEAN:
|
|
371
|
+
return isinstance(value, bool)
|
|
372
|
+
return isinstance(value, datetime.date) and not isinstance(value, datetime.datetime)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _is_scalar(value: object) -> bool:
|
|
376
|
+
return value is None or isinstance(value, (str, int, float, datetime.date))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def parse_conditions(
|
|
380
|
+
equalities: Sequence[str], lower: Sequence[str], upper: Sequence[str], types: Mapping[str, str]
|
|
381
|
+
) -> tuple[Conditions, list[str]]:
|
|
382
|
+
"""The conditions of `key=value` equalities and inclusive bounds, and every problem found.
|
|
383
|
+
|
|
384
|
+
`types` maps every name declared in the config to its type. A key no schema declares,
|
|
385
|
+
a value that does not convert to the type of its key, a bound on a key that is not a
|
|
386
|
+
number or a date, a null bound and a second bound of one kind on a key are refused.
|
|
387
|
+
"""
|
|
388
|
+
problems: list[str] = []
|
|
389
|
+
undeclared: list[str] = []
|
|
390
|
+
found_equalities: dict[str, list[object]] = {}
|
|
391
|
+
bounds: tuple[dict[str, object], dict[str, object]] = ({}, {})
|
|
392
|
+
for role, texts, bound in (
|
|
393
|
+
("condition", equalities, None),
|
|
394
|
+
("lower bound", lower, bounds[0]),
|
|
395
|
+
("upper bound", upper, bounds[1]),
|
|
396
|
+
):
|
|
397
|
+
for text in texts:
|
|
398
|
+
key, separator, value_text = text.partition(SET)
|
|
399
|
+
key = key.strip()
|
|
400
|
+
if not separator or not key:
|
|
401
|
+
problems.append(f"invalid {role} '{text}': use key=value")
|
|
402
|
+
continue
|
|
403
|
+
kind = types.get(key)
|
|
404
|
+
if kind is None:
|
|
405
|
+
if key not in undeclared:
|
|
406
|
+
undeclared.append(key)
|
|
407
|
+
continue
|
|
408
|
+
invalid = f"invalid {role} {key}={value_text}"
|
|
409
|
+
if bound is not None and kind not in BOUNDED:
|
|
410
|
+
problems.append(f"{invalid}: a bound takes a number or date field, and '{key}' is a {kind} field")
|
|
411
|
+
continue
|
|
412
|
+
try:
|
|
413
|
+
value = convert(kind, value_text)
|
|
414
|
+
except MetadataError as exc:
|
|
415
|
+
problems.append(f"{invalid}: metadata value of '{key}' {exc}")
|
|
416
|
+
continue
|
|
417
|
+
if bound is None:
|
|
418
|
+
found_equalities.setdefault(key, []).append(value)
|
|
419
|
+
elif value is None:
|
|
420
|
+
problems.append(f"{invalid}: a bound takes a value, not null")
|
|
421
|
+
elif key in bound:
|
|
422
|
+
problems.append(f"{invalid}: '{key}' takes one {role} at most")
|
|
423
|
+
else:
|
|
424
|
+
bound[key] = value
|
|
425
|
+
declared = ", ".join(types) if types else "none"
|
|
426
|
+
problems += [
|
|
427
|
+
f"undeclared metadata key '{key}': no metadata schema of the config declares it (declared: {declared})"
|
|
428
|
+
for key in undeclared
|
|
429
|
+
]
|
|
430
|
+
keys = [*found_equalities, *bounds[0], *bounds[1]]
|
|
431
|
+
conditions = Conditions(
|
|
432
|
+
equalities=found_equalities,
|
|
433
|
+
lower=bounds[0],
|
|
434
|
+
upper=bounds[1],
|
|
435
|
+
types={key: types[key] for key in keys},
|
|
436
|
+
)
|
|
437
|
+
return conditions, problems
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _same(value: object, wanted: object) -> bool:
|
|
441
|
+
"""Equality of two values of one type: `3` equals `3.0`, and `true` never equals `1`."""
|
|
442
|
+
return value == wanted and isinstance(value, bool) == isinstance(wanted, bool)
|
gemba/repo.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The layout of a tasks repo as `gemba init` creates it, and the rules of its convergence and its refusals."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
|
|
7
|
+
from gemba import config, skills, tasks
|
|
8
|
+
|
|
9
|
+
GITIGNORE = ".gitignore"
|
|
10
|
+
# The line of the .gitignore that keeps the harnesses, each a git repository of its own, out of the tasks repo.
|
|
11
|
+
IGNORED_LINE = "harnesses/"
|
|
12
|
+
# The token of the config template that the selected providers replace, as a flow list.
|
|
13
|
+
TEMPLATE_PROVIDERS_TOKEN = "__PROVIDERS__"
|
|
14
|
+
GITKEEP = PurePosixPath(tasks.TASKS_DIR, tasks.GITKEEP).as_posix()
|
|
15
|
+
# The last line of the output of init, whatever it wrote.
|
|
16
|
+
NEXT_STEP = "next step: open this tasks repo with your agent and ask it to configure gemba (the gemba-init skill)"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_config(providers: Sequence[str]) -> str:
|
|
20
|
+
"""The `gemba.yaml` init writes: the template asset with `providers` as a flow list."""
|
|
21
|
+
template = (resources.files("gemba") / "assets" / "init" / config.CONFIG_FILENAME).read_text(encoding="utf-8")
|
|
22
|
+
return template.replace(TEMPLATE_PROVIDERS_TOKEN, f"[{', '.join(providers)}]")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def gitignore_text(existing: str | None) -> str | None:
|
|
26
|
+
"""The `.gitignore` to write given the one on disk, or None when it already holds the line.
|
|
27
|
+
|
|
28
|
+
With no file, the line alone; with a file that lacks it, the file with the line appended
|
|
29
|
+
after a final newline.
|
|
30
|
+
"""
|
|
31
|
+
if existing is None:
|
|
32
|
+
return f"{IGNORED_LINE}\n"
|
|
33
|
+
if any(line.strip() == IGNORED_LINE for line in existing.splitlines()):
|
|
34
|
+
return None
|
|
35
|
+
separator = "\n" if existing and not existing.endswith("\n") else ""
|
|
36
|
+
return f"{existing}{separator}{IGNORED_LINE}\n"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def nested_problem(target: Path, found_root: Path) -> str:
|
|
40
|
+
"""Why `target` may not become a tasks repo: a strict ancestor, `found_root`, already is one."""
|
|
41
|
+
return f"{target} is inside the tasks repo {found_root}: a tasks repo is never created inside another one"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def providers_problem(given: Sequence[str]) -> list[str]:
|
|
45
|
+
"""Every unknown identifier of `--providers` and every identifier it repeats, each once."""
|
|
46
|
+
problems = []
|
|
47
|
+
seen: set[str] = set()
|
|
48
|
+
reported: set[str] = set()
|
|
49
|
+
for provider in given:
|
|
50
|
+
if provider in reported:
|
|
51
|
+
continue
|
|
52
|
+
if provider not in skills.PROVIDERS:
|
|
53
|
+
problems.append(f"unknown provider '{provider}' {_known()}")
|
|
54
|
+
reported.add(provider)
|
|
55
|
+
elif provider in seen:
|
|
56
|
+
problems.append(f"provider '{provider}' is listed more than once {_known()}")
|
|
57
|
+
reported.add(provider)
|
|
58
|
+
seen.add(provider)
|
|
59
|
+
return problems
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def conflict_problem() -> str:
|
|
63
|
+
"""Why `--providers` is refused once the tasks repo holds a config."""
|
|
64
|
+
return (
|
|
65
|
+
f"--providers is refused: {config.CONFIG_FILENAME} already declares the agent providers and is edited by "
|
|
66
|
+
"hand, never by gemba init"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def no_providers_problem() -> str:
|
|
71
|
+
"""Why the skills of an existing config that declares no providers cannot be written."""
|
|
72
|
+
return f"no agent providers declared: add agent_providers to {config.CONFIG_FILENAME} {_known()}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _known() -> str:
|
|
76
|
+
return f"(known providers: {', '.join(skills.PROVIDERS)})"
|
gemba/report.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Report rules: the groupings, counts and sums over tasks already selected.
|
|
2
|
+
|
|
3
|
+
The one module that knows tasks and metadata together: a task is grouped by its own
|
|
4
|
+
workstream and status, and by its metadata values read by the type of their field.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import itertools
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from gemba import metadata
|
|
12
|
+
from gemba.tasks import STATUSES, Task
|
|
13
|
+
|
|
14
|
+
# Properties of every task a report may group by; no metadata field may take these names.
|
|
15
|
+
BUILT_IN = metadata.RESERVED
|
|
16
|
+
# The types a report groups by: a number is summed and a date bounds a selection.
|
|
17
|
+
GROUPED = (metadata.TEXT, metadata.BOOLEAN)
|
|
18
|
+
SUMMED = (metadata.NUMBER,)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Sum:
|
|
23
|
+
"""The total of a number field over some tasks, and how many of them had no number to add."""
|
|
24
|
+
|
|
25
|
+
total: int | float
|
|
26
|
+
missing: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Group:
|
|
31
|
+
key: dict[str, object]
|
|
32
|
+
tasks: int
|
|
33
|
+
refs: list[str]
|
|
34
|
+
sums: dict[str, Sum]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Report:
|
|
39
|
+
"""The groups in order, then the total over every task, each task counted once."""
|
|
40
|
+
|
|
41
|
+
groups: list[Group]
|
|
42
|
+
tasks: int
|
|
43
|
+
sums: dict[str, Sum]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def grouping_problem(key: str, types: Mapping[str, str]) -> str | None:
|
|
47
|
+
"""Why a report cannot group by `key`, or None when it can."""
|
|
48
|
+
if key in BUILT_IN:
|
|
49
|
+
return None
|
|
50
|
+
if key not in types:
|
|
51
|
+
return f"cannot group by '{key}': it is not {', '.join(BUILT_IN)} or a field the config declares"
|
|
52
|
+
if types[key] not in GROUPED:
|
|
53
|
+
return (
|
|
54
|
+
f"cannot group by '{key}', a {types[key]} field: group by {', '.join(BUILT_IN)}, or a text or boolean field"
|
|
55
|
+
)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sum_problem(key: str, types: Mapping[str, str]) -> str | None:
|
|
60
|
+
"""Why a report cannot sum `key`, or None when it can."""
|
|
61
|
+
if key not in types:
|
|
62
|
+
return f"cannot sum '{key}': no metadata schema of the config declares it"
|
|
63
|
+
if types[key] not in SUMMED:
|
|
64
|
+
return f"cannot sum '{key}', a {types[key]} field: only a number field is summed"
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def problems(by: Sequence[str], sums: Sequence[str], types: Mapping[str, str]) -> list[str]:
|
|
69
|
+
"""Every grouping and sum a report cannot take, a key given twice to either included."""
|
|
70
|
+
found = []
|
|
71
|
+
for keys, problem, role in ((by, grouping_problem, "grouping"), (sums, sum_problem, "sum")):
|
|
72
|
+
for key in dict.fromkeys(keys):
|
|
73
|
+
if reason := problem(key, types):
|
|
74
|
+
found.append(reason)
|
|
75
|
+
if keys.count(key) > 1:
|
|
76
|
+
found.append(f"'{key}' is given more than once as a {role}")
|
|
77
|
+
return found
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def aggregate(
|
|
81
|
+
selected: Sequence[Task],
|
|
82
|
+
by: Sequence[str],
|
|
83
|
+
sums: Sequence[str],
|
|
84
|
+
types: Mapping[str, str],
|
|
85
|
+
workstreams: Sequence[str],
|
|
86
|
+
) -> Report:
|
|
87
|
+
"""The groups of the selected tasks, in listing order, and the total.
|
|
88
|
+
|
|
89
|
+
A task belongs to one group per combination of its grouping values: a null, missing
|
|
90
|
+
or mistyped value groups under null, and a text list under each distinct item, or
|
|
91
|
+
under null when empty. Groups are ordered grouping after grouping: workstreams in
|
|
92
|
+
the order of `workstreams`, statuses in their order, false before true, text by code
|
|
93
|
+
point and null last.
|
|
94
|
+
"""
|
|
95
|
+
members: dict[tuple[object, ...], list[Task]] = {}
|
|
96
|
+
for task in selected:
|
|
97
|
+
for combination in itertools.product(*(_grouping_values(task, key, types) for key in by)):
|
|
98
|
+
members.setdefault(combination, []).append(task)
|
|
99
|
+
order = sorted(
|
|
100
|
+
members,
|
|
101
|
+
key=lambda combination: [_rank(key, value, workstreams) for key, value in zip(by, combination, strict=True)],
|
|
102
|
+
)
|
|
103
|
+
groups = [
|
|
104
|
+
Group(
|
|
105
|
+
key=dict(zip(by, combination, strict=True)),
|
|
106
|
+
tasks=len(members[combination]),
|
|
107
|
+
refs=[task.ref for task in members[combination]],
|
|
108
|
+
sums=_sums(members[combination], sums),
|
|
109
|
+
)
|
|
110
|
+
for combination in order
|
|
111
|
+
]
|
|
112
|
+
return Report(groups=groups, tasks=len(selected), sums=_sums(selected, sums))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _grouping_values(task: Task, key: str, types: Mapping[str, str]) -> list[object]:
|
|
116
|
+
if key == "workstream":
|
|
117
|
+
return [task.workstream]
|
|
118
|
+
if key == "status":
|
|
119
|
+
return [task.status]
|
|
120
|
+
value = task.metadata.get(key)
|
|
121
|
+
if metadata.value_problem(types[key], value) is not None:
|
|
122
|
+
return [None]
|
|
123
|
+
if isinstance(value, list):
|
|
124
|
+
return list(dict.fromkeys(value)) or [None]
|
|
125
|
+
return [value]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _rank(key: str, value: object, workstreams: Sequence[str]) -> tuple[int, object]:
|
|
129
|
+
"""Where a grouping value sorts: the order of its grouping, null last."""
|
|
130
|
+
if value is None:
|
|
131
|
+
return (1, 0)
|
|
132
|
+
if key == "workstream":
|
|
133
|
+
return (0, workstreams.index(str(value)))
|
|
134
|
+
if key == "status":
|
|
135
|
+
return (0, STATUSES.index(str(value)))
|
|
136
|
+
if isinstance(value, bool):
|
|
137
|
+
return (0, int(value))
|
|
138
|
+
return (0, str(value))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _sums(group: Sequence[Task], keys: Sequence[str]) -> dict[str, Sum]:
|
|
142
|
+
sums = {}
|
|
143
|
+
for key in keys:
|
|
144
|
+
total: int | float = 0
|
|
145
|
+
missing = 0
|
|
146
|
+
for task in group:
|
|
147
|
+
value = task.metadata.get(key)
|
|
148
|
+
if value is None or metadata.value_problem(metadata.NUMBER, value) is not None:
|
|
149
|
+
missing += 1
|
|
150
|
+
else:
|
|
151
|
+
total = metadata.add(total, value)
|
|
152
|
+
sums[key] = Sum(total, missing)
|
|
153
|
+
return sums
|