prodc 0.3.7__tar.gz → 0.3.9__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.
- {prodc-0.3.7 → prodc-0.3.9}/PKG-INFO +29 -3
- {prodc-0.3.7 → prodc-0.3.9}/README.md +28 -2
- {prodc-0.3.7 → prodc-0.3.9}/pyproject.toml +1 -1
- {prodc-0.3.7 → prodc-0.3.9}/src/prodc/adapters.py +83 -0
- {prodc-0.3.7 → prodc-0.3.9}/src/prodc/cli.py +52 -0
- {prodc-0.3.7 → prodc-0.3.9}/src/prodc/config.py +15 -1
- {prodc-0.3.7 → prodc-0.3.9}/tests/test_adapters.py +29 -0
- {prodc-0.3.7 → prodc-0.3.9}/tests/test_cli.py +24 -0
- {prodc-0.3.7 → prodc-0.3.9}/.gitignore +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/CHANGELOG.md +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/LICENSE +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/src/prodc/__init__.py +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/src/prodc/loader.py +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/src/prodc/model.py +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/src/prodc/py.typed +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/src/prodc/status.py +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/tests/test_loader.py +0 -0
- {prodc-0.3.7 → prodc-0.3.9}/tests/test_status.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.9
|
|
4
4
|
Summary: Product management as code: personas, flows, and features traced to evidence.
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
|
|
6
6
|
Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
|
|
@@ -44,8 +44,34 @@ The lineage is Toulmin's argument model (claim + grounds + warrant): a feature i
|
|
|
44
44
|
its evidence is the grounds, its assumptions are the warrant. The same shape safety
|
|
45
45
|
engineering uses for a "safety case", pointed at product decisions.
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
## What it's for
|
|
48
|
+
|
|
49
|
+
prodc does not replace the human conversation with a client — it is scaffolding around it.
|
|
50
|
+
Going in, the board already shows what is still unknown for that client: needs with no
|
|
51
|
+
evidence (opinions) and needs with no proof (HOLEs), so the meeting is spent closing real
|
|
52
|
+
gaps instead of re-deriving them. Coming out, what was learned is captured as evidence on
|
|
53
|
+
the need, so the next decision is planned from the repo rather than from memory. The board
|
|
54
|
+
answers "where are we" and "what's blocking" between meetings, without a standup.
|
|
55
|
+
|
|
56
|
+
## Prior art
|
|
57
|
+
|
|
58
|
+
prodc draws on, and tries to credit, several existing lines of work:
|
|
59
|
+
|
|
60
|
+
- **Continuous discovery / opportunity framing** — Teresa Torres (opportunity = need; the
|
|
61
|
+
interview > artifact > internal evidence ladder; "solutions in disguise").
|
|
62
|
+
- **The argument/assurance model** — Toulmin (claim · grounds · warrant) and the safety-case
|
|
63
|
+
/ GSN tradition it informs.
|
|
64
|
+
- **Spec-driven development** — product/requirements modelled as plain JSON/YAML in git
|
|
65
|
+
(the ThoughtWorks / Fowler SDD writing).
|
|
66
|
+
- **Structured requirements** — EARS (Easy Approach to Requirements Syntax), the NASA
|
|
67
|
+
requirement-writing guidance, and the SysML lineage.
|
|
68
|
+
- **Requirements-as-code tooling** — Doorstop and StrictDoc (plain-text, git-tracked, linked
|
|
69
|
+
IDs), which prodc follows in form but points at product artifacts rather than "shall"
|
|
70
|
+
statements.
|
|
71
|
+
|
|
72
|
+
> Status: v0.3, on PyPI (`pip install prodc`). The model (Need · Source · Proof), the proof
|
|
73
|
+
> and source adapters, and the status / show / diff / portfolio commands are in; see
|
|
74
|
+
> `docs/issues/` for what's still open.
|
|
49
75
|
|
|
50
76
|
## Install
|
|
51
77
|
|
|
@@ -18,8 +18,34 @@ The lineage is Toulmin's argument model (claim + grounds + warrant): a feature i
|
|
|
18
18
|
its evidence is the grounds, its assumptions are the warrant. The same shape safety
|
|
19
19
|
engineering uses for a "safety case", pointed at product decisions.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
## What it's for
|
|
22
|
+
|
|
23
|
+
prodc does not replace the human conversation with a client — it is scaffolding around it.
|
|
24
|
+
Going in, the board already shows what is still unknown for that client: needs with no
|
|
25
|
+
evidence (opinions) and needs with no proof (HOLEs), so the meeting is spent closing real
|
|
26
|
+
gaps instead of re-deriving them. Coming out, what was learned is captured as evidence on
|
|
27
|
+
the need, so the next decision is planned from the repo rather than from memory. The board
|
|
28
|
+
answers "where are we" and "what's blocking" between meetings, without a standup.
|
|
29
|
+
|
|
30
|
+
## Prior art
|
|
31
|
+
|
|
32
|
+
prodc draws on, and tries to credit, several existing lines of work:
|
|
33
|
+
|
|
34
|
+
- **Continuous discovery / opportunity framing** — Teresa Torres (opportunity = need; the
|
|
35
|
+
interview > artifact > internal evidence ladder; "solutions in disguise").
|
|
36
|
+
- **The argument/assurance model** — Toulmin (claim · grounds · warrant) and the safety-case
|
|
37
|
+
/ GSN tradition it informs.
|
|
38
|
+
- **Spec-driven development** — product/requirements modelled as plain JSON/YAML in git
|
|
39
|
+
(the ThoughtWorks / Fowler SDD writing).
|
|
40
|
+
- **Structured requirements** — EARS (Easy Approach to Requirements Syntax), the NASA
|
|
41
|
+
requirement-writing guidance, and the SysML lineage.
|
|
42
|
+
- **Requirements-as-code tooling** — Doorstop and StrictDoc (plain-text, git-tracked, linked
|
|
43
|
+
IDs), which prodc follows in form but points at product artifacts rather than "shall"
|
|
44
|
+
statements.
|
|
45
|
+
|
|
46
|
+
> Status: v0.3, on PyPI (`pip install prodc`). The model (Need · Source · Proof), the proof
|
|
47
|
+
> and source adapters, and the status / show / diff / portfolio commands are in; see
|
|
48
|
+
> `docs/issues/` for what's still open.
|
|
23
49
|
|
|
24
50
|
## Install
|
|
25
51
|
|
|
@@ -274,6 +274,89 @@ class AcceptAdapter:
|
|
|
274
274
|
return ProofResult(ProofState.DELIVERED, True, detail)
|
|
275
275
|
|
|
276
276
|
|
|
277
|
+
class CoverageAdapter:
|
|
278
|
+
"""Resolves a category path against a catalog of existing categories (one per line).
|
|
279
|
+
|
|
280
|
+
DELIVERED when a category matches (already covered — point them at it); PENDING when
|
|
281
|
+
nothing matches (declared, not yet covered — an honest gap, not a build failure).
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
def __init__(self, root: Path, catalog: str) -> None:
|
|
285
|
+
self._path = root / catalog
|
|
286
|
+
self._cats: set[str] | None = None
|
|
287
|
+
|
|
288
|
+
def _load(self) -> set[str]:
|
|
289
|
+
if self._cats is None:
|
|
290
|
+
out: set[str] = set()
|
|
291
|
+
if self._path.exists():
|
|
292
|
+
for line in self._path.read_text(encoding="utf-8").splitlines():
|
|
293
|
+
s = line.strip()
|
|
294
|
+
if s and not s.startswith("#"):
|
|
295
|
+
out.add(s)
|
|
296
|
+
self._cats = out
|
|
297
|
+
return self._cats
|
|
298
|
+
|
|
299
|
+
def resolve(self, payload: str) -> ProofResult:
|
|
300
|
+
if payload in self._load():
|
|
301
|
+
return ProofResult(ProofState.DELIVERED, True, f"covered by '{payload}'")
|
|
302
|
+
return ProofResult(ProofState.PENDING, True, f"no category matches '{payload}'")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class FieldAdapter:
|
|
306
|
+
"""Resolves a field name against an upstream schema/sample JSON.
|
|
307
|
+
|
|
308
|
+
Schema: ``{"surfaced": [...], "received": [...]}``. DELIVERED if the field is surfaced;
|
|
309
|
+
PENDING otherwise, with a distinct detail for 'received but not surfaced' vs 'not
|
|
310
|
+
received upstream' — a different fix. Never a hard failure.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
def __init__(self, root: Path, schema: str) -> None:
|
|
314
|
+
self._path = root / schema
|
|
315
|
+
self._data: tuple[set[str], set[str]] | None = None
|
|
316
|
+
|
|
317
|
+
def _load(self) -> tuple[set[str], set[str]]:
|
|
318
|
+
if self._data is None:
|
|
319
|
+
surfaced: set[str] = set()
|
|
320
|
+
received: set[str] = set()
|
|
321
|
+
if self._path.exists():
|
|
322
|
+
raw: object = json.loads(self._path.read_text(encoding="utf-8"))
|
|
323
|
+
if isinstance(raw, dict):
|
|
324
|
+
d = cast("dict[str, object]", raw)
|
|
325
|
+
s, r = d.get("surfaced"), d.get("received")
|
|
326
|
+
if isinstance(s, list):
|
|
327
|
+
surfaced = {x for x in cast("list[object]", s) if isinstance(x, str)}
|
|
328
|
+
if isinstance(r, list):
|
|
329
|
+
received = {x for x in cast("list[object]", r) if isinstance(x, str)}
|
|
330
|
+
self._data = (surfaced, received)
|
|
331
|
+
return self._data
|
|
332
|
+
|
|
333
|
+
def resolve(self, payload: str) -> ProofResult:
|
|
334
|
+
surfaced, received = self._load()
|
|
335
|
+
if payload in surfaced:
|
|
336
|
+
return ProofResult(ProofState.DELIVERED, True, f"'{payload}' present and surfaced")
|
|
337
|
+
if payload in received:
|
|
338
|
+
return ProofResult(ProofState.PENDING, True, f"'{payload}' received but not surfaced")
|
|
339
|
+
return ProofResult(ProofState.PENDING, True, f"'{payload}' not received upstream")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class PrototypeAdapter:
|
|
343
|
+
"""Resolves ``ref@who@date`` to a prototype validated with users in discovery.
|
|
344
|
+
|
|
345
|
+
For perception/experience needs with no pass/fail test: a prototype shown to users in
|
|
346
|
+
an interview is real evidence of the need and the chosen direction. A *declared*
|
|
347
|
+
DELIVERED (``*``), distinct from a shipped-feature test and from a human sign-off
|
|
348
|
+
(accept) — this validates the need/direction, not the implementation.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
def resolve(self, payload: str) -> ProofResult:
|
|
352
|
+
ref, _, rest = payload.partition("@")
|
|
353
|
+
who, _, date = rest.partition("@")
|
|
354
|
+
detail = "prototype " + (ref or "?")
|
|
355
|
+
detail += f" shown to {who}" if who else ""
|
|
356
|
+
detail += f" on {date}" if date else ""
|
|
357
|
+
return ProofResult(ProofState.DELIVERED, True, detail)
|
|
358
|
+
|
|
359
|
+
|
|
277
360
|
class External:
|
|
278
361
|
"""Wraps a proof adapter that resolves against a sibling repo checked out at ``repo``.
|
|
279
362
|
|
|
@@ -13,6 +13,7 @@ import shutil
|
|
|
13
13
|
import sys
|
|
14
14
|
from dataclasses import asdict, dataclass
|
|
15
15
|
from pathlib import Path
|
|
16
|
+
from typing import cast
|
|
16
17
|
|
|
17
18
|
from . import __version__
|
|
18
19
|
from .config import Config, ConfigError
|
|
@@ -380,6 +381,51 @@ def _cmd_portfolio(args: argparse.Namespace) -> int:
|
|
|
380
381
|
return 2 if any(r.error is not None for r in rows) else 0
|
|
381
382
|
|
|
382
383
|
|
|
384
|
+
def _cmd_diff(args: argparse.Namespace) -> int:
|
|
385
|
+
"""Diff the current board against a prior `status --json` snapshot."""
|
|
386
|
+
loaded = _load_or_report(args)
|
|
387
|
+
if loaded is None:
|
|
388
|
+
return 2
|
|
389
|
+
cfg, needs = loaded
|
|
390
|
+
current = {r.need.id: r.state.value for r in resolve(needs, cfg.adapters, cfg.source_adapters)}
|
|
391
|
+
|
|
392
|
+
snap = Path(args.snapshot)
|
|
393
|
+
try:
|
|
394
|
+
payload: object = json.loads(snap.read_text(encoding="utf-8"))
|
|
395
|
+
except (OSError, ValueError) as exc:
|
|
396
|
+
print(f"prodc: cannot read snapshot {snap}: {exc}", file=sys.stderr)
|
|
397
|
+
return 2
|
|
398
|
+
raw_needs = cast("dict[str, object]", payload).get("needs") if isinstance(payload, dict) else None
|
|
399
|
+
old: dict[str, str] = {}
|
|
400
|
+
for n in cast("list[object]", raw_needs) if isinstance(raw_needs, list) else []:
|
|
401
|
+
if isinstance(n, dict):
|
|
402
|
+
entry = cast("dict[str, object]", n)
|
|
403
|
+
nid, st = entry.get("id"), entry.get("state")
|
|
404
|
+
if isinstance(nid, str) and isinstance(st, str):
|
|
405
|
+
old[nid] = st
|
|
406
|
+
|
|
407
|
+
new = {i: current[i] for i in sorted(current.keys() - old.keys())}
|
|
408
|
+
removed = sorted(old.keys() - current.keys())
|
|
409
|
+
changed = {
|
|
410
|
+
i: [old[i], current[i]] for i in sorted(old.keys() & current.keys()) if old[i] != current[i]
|
|
411
|
+
}
|
|
412
|
+
if args.json:
|
|
413
|
+
print(json.dumps({"new": new, "removed": removed, "changed": changed}, indent=2))
|
|
414
|
+
return 0
|
|
415
|
+
if not (new or removed or changed):
|
|
416
|
+
print("no changes since snapshot")
|
|
417
|
+
return 0
|
|
418
|
+
out: list[str] = []
|
|
419
|
+
for i, st in new.items():
|
|
420
|
+
out.append(f" new {i:<10} ({st})")
|
|
421
|
+
for i in removed:
|
|
422
|
+
out.append(f" removed {i}")
|
|
423
|
+
for i, (was, now) in changed.items():
|
|
424
|
+
out.append(f" changed {i:<10} {was} → {now}")
|
|
425
|
+
print("\n".join(out))
|
|
426
|
+
return 0
|
|
427
|
+
|
|
428
|
+
|
|
383
429
|
def _git_rev(root: Path) -> str | None:
|
|
384
430
|
head = root / ".git" / "HEAD"
|
|
385
431
|
try:
|
|
@@ -437,6 +483,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
437
483
|
pf.add_argument("--json", action="store_true", help="machine-readable output")
|
|
438
484
|
pf.set_defaults(func=_cmd_portfolio)
|
|
439
485
|
|
|
486
|
+
df = sub.add_parser("diff", help="diff the current board against a prior status --json snapshot")
|
|
487
|
+
df.add_argument("snapshot", help="path to a saved `prodc status --json` file")
|
|
488
|
+
df.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
|
|
489
|
+
df.add_argument("--json", action="store_true", help="machine-readable output")
|
|
490
|
+
df.set_defaults(func=_cmd_diff)
|
|
491
|
+
|
|
440
492
|
args = parser.parse_args(argv)
|
|
441
493
|
return int(args.func(args))
|
|
442
494
|
|
|
@@ -10,10 +10,13 @@ from typing import cast
|
|
|
10
10
|
from .adapters import (
|
|
11
11
|
AcceptAdapter,
|
|
12
12
|
Adapter,
|
|
13
|
+
CoverageAdapter,
|
|
13
14
|
External,
|
|
15
|
+
FieldAdapter,
|
|
14
16
|
FilesAdapter,
|
|
15
17
|
FrontmatterAdapter,
|
|
16
18
|
GherkinAdapter,
|
|
19
|
+
PrototypeAdapter,
|
|
17
20
|
PytestAdapter,
|
|
18
21
|
SourceAdapter,
|
|
19
22
|
)
|
|
@@ -89,9 +92,20 @@ class Config:
|
|
|
89
92
|
inner = FilesAdapter(root=proof_root / (sub if isinstance(sub, str) else "."))
|
|
90
93
|
elif kind == "accept":
|
|
91
94
|
inner = AcceptAdapter()
|
|
95
|
+
elif kind == "prototype":
|
|
96
|
+
inner = PrototypeAdapter()
|
|
97
|
+
elif kind == "coverage":
|
|
98
|
+
cat = tbl.get("catalog", "")
|
|
99
|
+
inner = CoverageAdapter(
|
|
100
|
+
root=proof_root, catalog=cat if isinstance(cat, str) else ""
|
|
101
|
+
)
|
|
102
|
+
elif kind == "field":
|
|
103
|
+
sch = tbl.get("schema", "")
|
|
104
|
+
inner = FieldAdapter(root=proof_root, schema=sch if isinstance(sch, str) else "")
|
|
92
105
|
else:
|
|
93
106
|
raise ConfigError(
|
|
94
|
-
f"[proofs.{name}]: unknown kind {kind!r}
|
|
107
|
+
f"[proofs.{name}]: unknown kind {kind!r} "
|
|
108
|
+
"(gherkin|pytest|files|accept|prototype|coverage|field)"
|
|
95
109
|
)
|
|
96
110
|
adapters[name] = (
|
|
97
111
|
External(inner, root / repo_field) if isinstance(repo_field, str) else inner
|
|
@@ -6,9 +6,12 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
from prodc.adapters import (
|
|
8
8
|
AcceptAdapter,
|
|
9
|
+
CoverageAdapter,
|
|
9
10
|
External,
|
|
11
|
+
FieldAdapter,
|
|
10
12
|
FilesAdapter,
|
|
11
13
|
FrontmatterAdapter,
|
|
14
|
+
PrototypeAdapter,
|
|
12
15
|
PytestAdapter,
|
|
13
16
|
)
|
|
14
17
|
from prodc.model import ProofState
|
|
@@ -92,6 +95,32 @@ def test_external_unresolved_when_sibling_absent(tmp_path: Path) -> None:
|
|
|
92
95
|
assert r.state is ProofState.UNRESOLVED and not r.declared
|
|
93
96
|
|
|
94
97
|
|
|
98
|
+
def test_prototype_adapter_declared_delivered() -> None:
|
|
99
|
+
r = PrototypeAdapter().resolve("fig-12@users@2026-09-20")
|
|
100
|
+
assert r.state is ProofState.DELIVERED and r.declared
|
|
101
|
+
assert "prototype fig-12" in r.detail and "users" in r.detail
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_coverage_adapter(tmp_path: Path) -> None:
|
|
105
|
+
(tmp_path / "catalog.txt").write_text("billing/invoices\n# a comment\nroasting/queue\n",
|
|
106
|
+
encoding="utf-8")
|
|
107
|
+
a = CoverageAdapter(root=tmp_path, catalog="catalog.txt")
|
|
108
|
+
assert a.resolve("billing/invoices").state is ProofState.DELIVERED
|
|
109
|
+
assert a.resolve("shipping/labels").state is ProofState.PENDING # not yet covered
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_field_adapter(tmp_path: Path) -> None:
|
|
113
|
+
(tmp_path / "schema.json").write_text(
|
|
114
|
+
'{"surfaced": ["total"], "received": ["total", "tax_id"]}', encoding="utf-8"
|
|
115
|
+
)
|
|
116
|
+
a = FieldAdapter(root=tmp_path, schema="schema.json")
|
|
117
|
+
assert a.resolve("total").state is ProofState.DELIVERED
|
|
118
|
+
r = a.resolve("tax_id")
|
|
119
|
+
assert r.state is ProofState.PENDING and "received but not surfaced" in r.detail
|
|
120
|
+
r2 = a.resolve("missing")
|
|
121
|
+
assert r2.state is ProofState.PENDING and "not received upstream" in r2.detail
|
|
122
|
+
|
|
123
|
+
|
|
95
124
|
def test_external_reads_sibling_and_records_rev(tmp_path: Path) -> None:
|
|
96
125
|
sib = tmp_path / "sib"
|
|
97
126
|
sib.mkdir()
|
|
@@ -179,6 +179,30 @@ def test_portfolio_bad_config_exits_2(tmp_path: Path, capsys: pytest.CaptureFixt
|
|
|
179
179
|
assert code == 2
|
|
180
180
|
|
|
181
181
|
|
|
182
|
+
def test_diff_no_changes(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
183
|
+
toml = str(_project(tmp_path))
|
|
184
|
+
main(["status", "--config", toml, "--json"])
|
|
185
|
+
snap = tmp_path / "snap.json"
|
|
186
|
+
snap.write_text(capsys.readouterr().out, encoding="utf-8")
|
|
187
|
+
main(["diff", str(snap), "--config", toml])
|
|
188
|
+
assert "no changes" in capsys.readouterr().out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_diff_reports_new_need(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
192
|
+
toml = str(_project(tmp_path))
|
|
193
|
+
main(["status", "--config", toml, "--json"])
|
|
194
|
+
snap = tmp_path / "snap.json"
|
|
195
|
+
snap.write_text(capsys.readouterr().out, encoding="utf-8")
|
|
196
|
+
# add a brand-new need, then diff against the snapshot
|
|
197
|
+
(tmp_path / "needs" / "a.yaml").write_text(
|
|
198
|
+
_NEEDS + " - id: X-9\n text: a newly added need\n", encoding="utf-8"
|
|
199
|
+
)
|
|
200
|
+
code = main(["diff", str(snap), "--config", toml])
|
|
201
|
+
out = capsys.readouterr().out
|
|
202
|
+
assert code == 0
|
|
203
|
+
assert "new" in out and "X-9" in out
|
|
204
|
+
|
|
205
|
+
|
|
182
206
|
def test_proxy_source_kind_warrant(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
183
207
|
import json
|
|
184
208
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|