docassert 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.
Files changed (86) hide show
  1. docassert/__init__.py +8 -0
  2. docassert/__main__.py +6 -0
  3. docassert/_data/consistency.yaml +51 -0
  4. docassert/_data/criteria/adr.criteria.yaml +36 -0
  5. docassert/_data/criteria/benefits-realization.criteria.yaml +30 -0
  6. docassert/_data/criteria/brd.criteria.yaml +30 -0
  7. docassert/_data/criteria/business-case.criteria.yaml +23 -0
  8. docassert/_data/criteria/charter.criteria.yaml +73 -0
  9. docassert/_data/criteria/data-migration-plan.criteria.yaml +28 -0
  10. docassert/_data/criteria/frnfr.criteria.yaml +31 -0
  11. docassert/_data/criteria/hypercare-plan.criteria.yaml +27 -0
  12. docassert/_data/criteria/post-implementation-review.criteria.yaml +24 -0
  13. docassert/_data/criteria/prd.criteria.yaml +31 -0
  14. docassert/_data/criteria/project.criteria.yaml +32 -0
  15. docassert/_data/criteria/qa-test-plan.criteria.yaml +27 -0
  16. docassert/_data/criteria/raci-stakeholder.criteria.yaml +24 -0
  17. docassert/_data/criteria/release-cutover-plan.criteria.yaml +30 -0
  18. docassert/_data/criteria/risk-register.criteria.yaml +32 -0
  19. docassert/_data/criteria/rollback-plan.criteria.yaml +29 -0
  20. docassert/_data/criteria/runbook.criteria.yaml +30 -0
  21. docassert/_data/criteria/status-report.criteria.yaml +26 -0
  22. docassert/_data/criteria/test-cases.criteria.yaml +28 -0
  23. docassert/_data/criteria/user-story.criteria.yaml +32 -0
  24. docassert/_data/profiles/agile-delivery.yaml +20 -0
  25. docassert/_data/profiles/lean-startup.yaml +19 -0
  26. docassert/_data/profiles/regulated-industry.yaml +31 -0
  27. docassert/_data/schema/adr.schema.json +45 -0
  28. docassert/_data/schema/benefits-realization.schema.json +45 -0
  29. docassert/_data/schema/brd.schema.json +45 -0
  30. docassert/_data/schema/business-case.schema.json +45 -0
  31. docassert/_data/schema/charter.schema.json +84 -0
  32. docassert/_data/schema/data-migration-plan.schema.json +45 -0
  33. docassert/_data/schema/frnfr.schema.json +45 -0
  34. docassert/_data/schema/hypercare-plan.schema.json +45 -0
  35. docassert/_data/schema/post-implementation-review.schema.json +45 -0
  36. docassert/_data/schema/prd.schema.json +45 -0
  37. docassert/_data/schema/project.schema.json +32 -0
  38. docassert/_data/schema/qa-test-plan.schema.json +45 -0
  39. docassert/_data/schema/raci-stakeholder.schema.json +45 -0
  40. docassert/_data/schema/release-cutover-plan.schema.json +45 -0
  41. docassert/_data/schema/risk-register.schema.json +45 -0
  42. docassert/_data/schema/rollback-plan.schema.json +45 -0
  43. docassert/_data/schema/runbook.schema.json +45 -0
  44. docassert/_data/schema/status-report.schema.json +58 -0
  45. docassert/_data/schema/test-cases.schema.json +45 -0
  46. docassert/_data/schema/user-story.schema.json +45 -0
  47. docassert/_data/templates/adr.template.md +17 -0
  48. docassert/_data/templates/benefits-realization.template.md +25 -0
  49. docassert/_data/templates/brd.template.md +22 -0
  50. docassert/_data/templates/business-case.template.md +27 -0
  51. docassert/_data/templates/charter.template.md +46 -0
  52. docassert/_data/templates/data-migration-plan.template.md +35 -0
  53. docassert/_data/templates/frnfr.template.md +19 -0
  54. docassert/_data/templates/hypercare-plan.template.md +29 -0
  55. docassert/_data/templates/post-implementation-review.template.md +31 -0
  56. docassert/_data/templates/prd.template.md +23 -0
  57. docassert/_data/templates/project.template.md +17 -0
  58. docassert/_data/templates/qa-test-plan.template.md +31 -0
  59. docassert/_data/templates/raci-stakeholder.template.md +21 -0
  60. docassert/_data/templates/release-cutover-plan.template.md +28 -0
  61. docassert/_data/templates/risk-register.template.md +18 -0
  62. docassert/_data/templates/rollback-plan.template.md +24 -0
  63. docassert/_data/templates/runbook.template.md +28 -0
  64. docassert/_data/templates/status-report.template.md +27 -0
  65. docassert/_data/templates/test-cases.template.md +17 -0
  66. docassert/_data/templates/user-story.template.md +17 -0
  67. docassert/cli.py +291 -0
  68. docassert/config.py +104 -0
  69. docassert/consistency.py +167 -0
  70. docassert/graph.py +68 -0
  71. docassert/loader.py +116 -0
  72. docassert/models.py +99 -0
  73. docassert/profiles.py +111 -0
  74. docassert/projects.py +49 -0
  75. docassert/report.py +83 -0
  76. docassert/rtm.py +70 -0
  77. docassert/semantic.py +124 -0
  78. docassert/status.py +538 -0
  79. docassert/structural.py +406 -0
  80. docassert-0.1.0.dist-info/METADATA +125 -0
  81. docassert-0.1.0.dist-info/RECORD +86 -0
  82. docassert-0.1.0.dist-info/WHEEL +5 -0
  83. docassert-0.1.0.dist-info/entry_points.txt +2 -0
  84. docassert-0.1.0.dist-info/licenses/LICENSE +201 -0
  85. docassert-0.1.0.dist-info/licenses/NOTICE +4 -0
  86. docassert-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,31 @@
1
+ ---
2
+ kind: qa-test-plan
3
+ id: my-test-plan
4
+ title: My QA / Test Plan
5
+ owner: jane.doe
6
+ status: draft
7
+ ---
8
+
9
+ ## Scope
10
+
11
+ <!-- What is being tested (and what isn't). -->
12
+
13
+ ## Test Approach
14
+
15
+ <!-- Types of testing, tooling, and how test cases map to requirements. -->
16
+
17
+ ## Environments
18
+
19
+ <!-- The environments used and their configuration. -->
20
+
21
+ ## Entry Criteria
22
+
23
+ <!-- What must be true before testing starts. -->
24
+
25
+ - <!-- e.g. every acceptance criterion has at least one test case -->
26
+
27
+ ## Exit Criteria
28
+
29
+ <!-- Each exit criterion must state a measurable threshold. -->
30
+
31
+ - <!-- e.g. at least 95% of test cases pass -->
@@ -0,0 +1,21 @@
1
+ ---
2
+ kind: raci-stakeholder
3
+ id: my-raci
4
+ title: My RACI / Stakeholder Register
5
+ owner: jane.doe
6
+ status: draft
7
+ ---
8
+
9
+ ## Stakeholders
10
+
11
+ <!-- One bullet per stakeholder and their interest. -->
12
+
13
+ - <!-- Sponsor — name (accountable for benefits) -->
14
+
15
+ ## RACI Matrix
16
+
17
+ <!-- Exactly one Accountable (A) per activity row. R/A/C/I. -->
18
+
19
+ | Activity | Sponsor | Delivery Lead | QA Lead |
20
+ |---|---|---|---|
21
+ | Approve charter | A | C | I |
@@ -0,0 +1,28 @@
1
+ ---
2
+ kind: release-cutover-plan
3
+ id: my-cutover-plan
4
+ title: My Release / Cutover Plan
5
+ owner: jane.doe
6
+ status: draft
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ <!-- What is being released and when. -->
12
+
13
+ ## Pre-Cutover Checklist
14
+
15
+ - <!-- e.g. all tests green; backups taken -->
16
+
17
+ ## Cutover Steps
18
+
19
+ 1. <!-- first step -->
20
+ 2. <!-- second step -->
21
+
22
+ ## Verification
23
+
24
+ <!-- How you confirm the release succeeded. -->
25
+
26
+ ## Rollback Trigger
27
+
28
+ <!-- The conditions under which you invoke the rollback plan. -->
@@ -0,0 +1,18 @@
1
+ ---
2
+ kind: risk-register
3
+ id: my-risk-register
4
+ title: My Risk Register
5
+ owner: jane.doe
6
+ status: draft
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ <!-- What this register covers. -->
12
+
13
+ ## Risks
14
+
15
+ <!-- Each risk optionally threatens a requirement or milestone, and MUST state
16
+ Probability, Impact, Owner, and Response. -->
17
+
18
+ - **RISK-001** (threatens: BR-001): <!-- description -->. Probability: Medium. Impact: High. Owner: jane.doe. Response: <!-- mitigation --> .
@@ -0,0 +1,24 @@
1
+ ---
2
+ kind: rollback-plan
3
+ id: my-rollback-plan
4
+ title: My Rollback Plan
5
+ owner: jane.doe
6
+ status: draft
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ <!-- What this rolls back and to what state. -->
12
+
13
+ ## Trigger Conditions
14
+
15
+ <!-- The conditions that cause a rollback. -->
16
+
17
+ ## Rollback Steps
18
+
19
+ 1. <!-- first step -->
20
+ 2. <!-- second step -->
21
+
22
+ ## Verification
23
+
24
+ <!-- How you confirm the rollback restored a good state. -->
@@ -0,0 +1,28 @@
1
+ ---
2
+ kind: runbook
3
+ id: my-runbook
4
+ title: My Runbook
5
+ owner: jane.doe
6
+ status: draft
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ <!-- What this runbook operates. -->
12
+
13
+ ## Prerequisites
14
+
15
+ <!-- Access, tools, and preconditions. -->
16
+
17
+ ## Procedures
18
+
19
+ 1. <!-- first operational step -->
20
+ 2. <!-- second operational step -->
21
+
22
+ ## Monitoring
23
+
24
+ <!-- Dashboards, alerts, and what "healthy" looks like. -->
25
+
26
+ ## Escalation
27
+
28
+ <!-- Who to contact when a procedure fails. -->
@@ -0,0 +1,27 @@
1
+ ---
2
+ kind: status-report
3
+ id: my-status-2026-07-01
4
+ title: My Project — Status Report
5
+ owner: jane.doe
6
+ period: 2026-07-01
7
+ rag: green # green | amber | red
8
+ status: draft
9
+ ---
10
+
11
+ ## Summary
12
+
13
+ <!-- One paragraph: overall health and the headline. -->
14
+
15
+ ## Progress
16
+
17
+ <!-- What moved since the last report. -->
18
+
19
+ ## Risks & Issues
20
+
21
+ <!-- Cite risks by their register ID, e.g. RISK-001. -->
22
+
23
+ - <!-- RISK-001: current state and any change -->
24
+
25
+ ## Next Steps
26
+
27
+ <!-- What happens before the next report. -->
@@ -0,0 +1,17 @@
1
+ ---
2
+ kind: test-cases
3
+ id: my-test-cases
4
+ title: My Test Cases
5
+ owner: jane.doe
6
+ status: draft
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ <!-- What these test cases cover. -->
12
+
13
+ ## Test Cases
14
+
15
+ <!-- Each test case tests one or more acceptance criteria. -->
16
+
17
+ - **TC-001** (tests: AC-001): <!-- Steps: … Expected: … -->
@@ -0,0 +1,17 @@
1
+ ---
2
+ kind: user-story
3
+ id: my-user-stories
4
+ title: My User Stories
5
+ owner: jane.doe
6
+ status: draft
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ <!-- What area these stories cover. -->
12
+
13
+ ## User Stories
14
+
15
+ <!-- Each story traces to a product requirement and follows the standard form. -->
16
+
17
+ - **US-001** (traces: PR-001): As a <role>, I want <goal>, so that <benefit>.
docassert/cli.py ADDED
@@ -0,0 +1,291 @@
1
+ """docassert command-line interface.
2
+
3
+ docassert validate documents/charters/aurora.md
4
+ docassert validate documents/**/*.md --junit out.xml --markdown comment.md
5
+
6
+ Exit code = number of BLOCKING (structural) failures. Advisory (AI) failures
7
+ never affect the exit code, so CI is gated only by deterministic checks.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import glob
13
+ import os
14
+ import sys
15
+ from collections import defaultdict
16
+ from pathlib import Path
17
+
18
+ from . import config, report, rtm
19
+ from .consistency import run_consistency
20
+ from .graph import build_graph
21
+ from .loader import load
22
+ from .models import CheckResult
23
+ from .semantic import run_semantic
24
+ from .structural import run_structural
25
+
26
+ # The user's documents live here; criteria / schema / consistency.yaml / profiles
27
+ # resolve via `config` (local override → packaged default).
28
+ DOCUMENTS_DIR = Path("documents")
29
+
30
+
31
+ def _build_id_index() -> dict[str, list[str]]:
32
+ """Map document id -> [paths] across all documents/, for uniqueness checks."""
33
+ index: dict[str, list[str]] = defaultdict(list)
34
+ for path in DOCUMENTS_DIR.rglob("*.md"):
35
+ try:
36
+ doc = load(path)
37
+ except ValueError:
38
+ continue
39
+ if doc.id:
40
+ index[doc.id].append(str(path))
41
+ return index
42
+
43
+
44
+ def _validate_one(path: str, id_index: dict) -> list[CheckResult]:
45
+ doc = load(path)
46
+ kind = doc.kind or "charter"
47
+ criteria = config.read_criteria(kind)
48
+ schema = config.read_schema(kind)
49
+
50
+ ctx = {
51
+ "schema": schema,
52
+ "required_sections": criteria.get("required_sections", []),
53
+ "item_sections": criteria.get("item_sections", []),
54
+ "steps_sections": criteria.get("steps_sections", []),
55
+ "measurable_sections": criteria.get("measurable_sections", []),
56
+ "id_index": id_index,
57
+ }
58
+ content = Path(path).read_text(encoding="utf-8")
59
+
60
+ results: list[CheckResult] = []
61
+ for spec in criteria.get("checks", []):
62
+ if spec.get("type") == "structural":
63
+ results.append(run_structural(doc, spec, ctx))
64
+ elif spec.get("type") == "semantic":
65
+ results.append(run_semantic(doc, spec, content))
66
+ return results
67
+
68
+
69
+ def _expand(paths: list[str]) -> list[str]:
70
+ files: list[str] = []
71
+ for p in paths:
72
+ matched = glob.glob(p, recursive=True)
73
+ files.extend(matched if matched else [p])
74
+ # de-dupe; keep only markdown docs that still exist (skip files a PR deleted)
75
+ seen, out = set(), []
76
+ for f in files:
77
+ if f.endswith(".md") and f not in seen and os.path.isfile(f):
78
+ seen.add(f)
79
+ out.append(f)
80
+ return out
81
+
82
+
83
+ def cmd_validate(args: argparse.Namespace) -> int:
84
+ files = _expand(args.paths)
85
+ if not files:
86
+ print("docassert: no markdown documents matched.", file=sys.stderr)
87
+ return 0
88
+
89
+ id_index = _build_id_index()
90
+ results_by_doc: dict[str, list[CheckResult]] = {}
91
+ for path in files:
92
+ try:
93
+ results_by_doc[path] = _validate_one(path, id_index)
94
+ except FileNotFoundError as exc:
95
+ print(f"docassert: {exc}", file=sys.stderr)
96
+ return 2
97
+ except ValueError as exc: # malformed frontmatter → a real, blocking failure
98
+ results_by_doc[path] = [CheckResult(
99
+ "parse", False, True, str(exc), kind="structural")]
100
+
101
+ print(report.console(results_by_doc))
102
+ print("\n" + report.summary_line(results_by_doc))
103
+
104
+ if args.junit:
105
+ Path(args.junit).write_text(report.junit(results_by_doc))
106
+ if args.markdown:
107
+ Path(args.markdown).write_text(report.markdown(results_by_doc))
108
+
109
+ return sum(1 for rs in results_by_doc.values()
110
+ for r in rs if r.is_blocking_failure)
111
+
112
+
113
+ def cmd_consistency(args: argparse.Namespace) -> int:
114
+ results = run_consistency(DOCUMENTS_DIR, with_semantic=not args.no_semantic)
115
+ results_by_doc = {"consistency (cross-document)": results}
116
+
117
+ print(report.console(results_by_doc))
118
+ print("\n" + report.summary_line(results_by_doc))
119
+
120
+ if args.junit:
121
+ Path(args.junit).write_text(report.junit(results_by_doc))
122
+ if args.markdown:
123
+ Path(args.markdown).write_text(
124
+ report.markdown(results_by_doc, title="docassert consistency"))
125
+
126
+ return sum(1 for r in results if r.is_blocking_failure)
127
+
128
+
129
+ def _project_code(value: str | None) -> str | None:
130
+ """Accept either a PRJ-NNN-CODE id or a bare CODE; return the CODE."""
131
+ return value.split("-")[-1] if value else None
132
+
133
+
134
+ def cmd_rtm(args: argparse.Namespace) -> int:
135
+ graph = build_graph(DOCUMENTS_DIR)
136
+ code = _project_code(args.project)
137
+ text = rtm.render_csv(graph, code) if args.csv else rtm.render_markdown(graph, code)
138
+ if args.out:
139
+ Path(args.out).write_text(text)
140
+ print(f"docassert: wrote {args.out}")
141
+ else:
142
+ sys.stdout.write(text)
143
+ return 0
144
+
145
+
146
+ def cmd_projects(args: argparse.Namespace) -> int:
147
+ from . import projects as proj
148
+ plist = proj.load_projects(DOCUMENTS_DIR)
149
+ issues = proj.registry_issues(plist)
150
+ for issue in issues:
151
+ print(f"docassert: {issue}", file=sys.stderr)
152
+ text = proj.render_yaml(plist)
153
+
154
+ if args.check:
155
+ current = Path(args.out or "projects.yaml")
156
+ existing = current.read_text() if current.is_file() else ""
157
+ if existing != text:
158
+ print(f"docassert: {current} is stale — run `docassert projects --out {current}`",
159
+ file=sys.stderr)
160
+ return 1
161
+ print(f"docassert: {current} is up to date ({len(plist)} projects).")
162
+ return 1 if issues else 0
163
+
164
+ if args.out:
165
+ Path(args.out).write_text(text)
166
+ print(f"docassert: wrote {args.out} ({len(plist)} projects)")
167
+ else:
168
+ sys.stdout.write(text)
169
+ return 1 if issues else 0
170
+
171
+
172
+ def cmd_status(args: argparse.Namespace) -> int:
173
+ from . import status as status_mod
174
+ if args.index:
175
+ index = status_mod.build_index(DOCUMENTS_DIR)
176
+ if args.format == "json":
177
+ text = status_mod.render_json(index)
178
+ elif args.format == "html":
179
+ text = status_mod.render_index_html(index)
180
+ else:
181
+ text = status_mod.render_index_markdown(index)
182
+ tag = index["overall"]["rag"]
183
+ else:
184
+ model = status_mod.build_status(DOCUMENTS_DIR, project=args.project)
185
+ if args.project and not model["documents"]:
186
+ print(f"docassert: no documents for project {args.project!r}", file=sys.stderr)
187
+ return 2
188
+ if args.format == "json":
189
+ text = status_mod.render_json(model)
190
+ elif args.format == "html":
191
+ text = status_mod.render_html(model)
192
+ else:
193
+ text = status_mod.render_markdown(model, summary=args.summary)
194
+ tag = model["rag"]
195
+ if args.out:
196
+ Path(args.out).write_text(text)
197
+ print(f"docassert: wrote {args.out} (status: {tag})")
198
+ else:
199
+ sys.stdout.write(text)
200
+ return 0
201
+
202
+
203
+ def cmd_pages(args: argparse.Namespace) -> int:
204
+ """Build the whole Pages site: a portfolio index plus one page per project."""
205
+ from . import projects as projects_mod
206
+ from . import status as status_mod
207
+ out = Path(args.out)
208
+ out.mkdir(parents=True, exist_ok=True)
209
+
210
+ index = status_mod.build_index(DOCUMENTS_DIR)
211
+ (out / "index.html").write_text(status_mod.render_index_html(index))
212
+
213
+ plist = projects_mod.load_projects(DOCUMENTS_DIR)
214
+ for p in plist:
215
+ model = status_mod.build_status(DOCUMENTS_DIR, project=p["id"])
216
+ (out / f"{p['id']}.html").write_text(status_mod.render_html(model))
217
+
218
+ (out / "RTM.md").write_text(rtm.render_markdown(build_graph(DOCUMENTS_DIR)))
219
+ print(f"docassert: wrote {out}/ — index + {len(plist)} project page(s) + RTM.md "
220
+ f"(portfolio: {index['overall']['rag']})")
221
+ return 0
222
+
223
+
224
+ def cmd_init(args: argparse.Namespace) -> int:
225
+ """Scaffold a repo with the default criteria, schema, profiles, templates,
226
+ and consistency.yaml so a team can customize the standard."""
227
+ created = config.init(args.dir)
228
+ if created:
229
+ print(f"docassert: scaffolded {', '.join(created)} in {args.dir}/")
230
+ else:
231
+ print(f"docassert: nothing to do — {args.dir}/ already has the config files")
232
+ return 0
233
+
234
+
235
+ def main(argv: list[str] | None = None) -> int:
236
+ from . import __version__
237
+ parser = argparse.ArgumentParser(prog="docassert",
238
+ description="Unit testing for business documents.")
239
+ parser.add_argument("--version", action="version", version=f"docassert {__version__}")
240
+ sub = parser.add_subparsers(dest="command", required=True)
241
+
242
+ v = sub.add_parser("validate", help="Validate documents against their criteria.")
243
+ v.add_argument("paths", nargs="+", help="Markdown files or globs.")
244
+ v.add_argument("--junit", help="Write a JUnit XML report to this path.")
245
+ v.add_argument("--markdown", help="Write a PR-comment markdown report to this path.")
246
+ v.set_defaults(func=cmd_validate)
247
+
248
+ c = sub.add_parser("consistency", help="Check cross-document traceability.")
249
+ c.add_argument("--junit", help="Write a JUnit XML report to this path.")
250
+ c.add_argument("--markdown", help="Write a PR-comment markdown report to this path.")
251
+ c.add_argument("--no-semantic", action="store_true",
252
+ help="Skip AI alignment (structural consistency only).")
253
+ c.set_defaults(func=cmd_consistency)
254
+
255
+ r = sub.add_parser("rtm", help="Generate the requirements traceability matrix.")
256
+ r.add_argument("--out", help="Write to this path instead of stdout.")
257
+ r.add_argument("--csv", action="store_true", help="Emit CSV instead of Markdown.")
258
+ r.add_argument("--project", help="Scope to one project (PRJ-NNN-CODE id or CODE).")
259
+ r.set_defaults(func=cmd_rtm)
260
+
261
+ s = sub.add_parser("status", help="Derive a project status page from the documents.")
262
+ s.add_argument("--format", choices=["md", "json", "html"], default="md",
263
+ help="Output format (default: md).")
264
+ s.add_argument("--summary", action="store_true",
265
+ help="Condensed markdown (RAG + signals, no inventory table).")
266
+ s.add_argument("--project", help="Scope the status to one project (its PRJ-NNN-CODE id).")
267
+ s.add_argument("--index", action="store_true",
268
+ help="Render the multi-project portfolio index instead of one status.")
269
+ s.add_argument("--out", help="Write to this path instead of stdout.")
270
+ s.set_defaults(func=cmd_status)
271
+
272
+ pg = sub.add_parser("pages", help="Build the full Pages site (portfolio index + a page per project).")
273
+ pg.add_argument("--out", default="_site", help="Output directory (default: _site).")
274
+ pg.set_defaults(func=cmd_pages)
275
+
276
+ p = sub.add_parser("projects", help="Generate the project registry from the project.md anchors.")
277
+ p.add_argument("--out", help="Write to this path instead of stdout (e.g. projects.yaml).")
278
+ p.add_argument("--check", action="store_true",
279
+ help="Exit non-zero if the registry file is stale (CI freshness gate).")
280
+ p.set_defaults(func=cmd_projects)
281
+
282
+ ini = sub.add_parser("init", help="Scaffold the default criteria/schema/profiles/templates into a repo.")
283
+ ini.add_argument("dir", nargs="?", default=".", help="Target directory (default: current).")
284
+ ini.set_defaults(func=cmd_init)
285
+
286
+ args = parser.parse_args(argv)
287
+ return args.func(args)
288
+
289
+
290
+ if __name__ == "__main__":
291
+ sys.exit(main())
docassert/config.py ADDED
@@ -0,0 +1,104 @@
1
+ """Configuration resolution: local repo override → packaged default.
2
+
3
+ docassert ships default criteria, schemas, profiles, templates, and
4
+ consistency.yaml as package data under ``docassert/_data/``. At runtime each is
5
+ resolved from the current working directory first (so a repo can define its own
6
+ standard), falling back to the packaged default — which is what makes
7
+ ``docassert`` usable in a repo that hasn't set up its own config.
8
+
9
+ ``docassert init`` copies the packaged defaults into a repo so they can be edited.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import shutil
15
+ from importlib.resources import files
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+
20
+ # A concrete filesystem path to the bundled defaults (docassert is installed
21
+ # unzipped, so this resolves to a real directory for both wheel and editable installs).
22
+ DATA_DIR = Path(str(files("docassert"))) / "_data"
23
+
24
+
25
+ def _resolve(local: Path, packaged_rel: str) -> Path | None:
26
+ """The local file if it exists, else the packaged default, else None."""
27
+ if local.is_file():
28
+ return local
29
+ packaged = DATA_DIR / packaged_rel
30
+ return packaged if packaged.is_file() else None
31
+
32
+
33
+ def _read_yaml(path: Path) -> dict:
34
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
35
+
36
+
37
+ # ── criteria ────────────────────────────────────────────────────────────────
38
+ def criteria_path(kind: str) -> Path | None:
39
+ return _resolve(Path("criteria") / f"{kind}.criteria.yaml",
40
+ f"criteria/{kind}.criteria.yaml")
41
+
42
+
43
+ def criteria_exists(kind: str) -> bool:
44
+ return criteria_path(kind) is not None
45
+
46
+
47
+ def read_criteria(kind: str) -> dict:
48
+ path = criteria_path(kind)
49
+ if path is None:
50
+ raise FileNotFoundError(
51
+ f"no criteria for kind '{kind}' (looked in ./criteria and packaged defaults)")
52
+ return _read_yaml(path)
53
+
54
+
55
+ # ── schema ──────────────────────────────────────────────────────────────────
56
+ def read_schema(kind: str) -> dict:
57
+ path = _resolve(Path("schema") / f"{kind}.schema.json", f"schema/{kind}.schema.json")
58
+ if path is None:
59
+ raise FileNotFoundError(f"no schema for kind '{kind}'")
60
+ return json.loads(path.read_text(encoding="utf-8"))
61
+
62
+
63
+ # ── consistency config ──────────────────────────────────────────────────────
64
+ def read_consistency_config() -> dict:
65
+ path = _resolve(Path("consistency.yaml"), "consistency.yaml")
66
+ return _read_yaml(path) if path is not None else {}
67
+
68
+
69
+ # ── profiles ────────────────────────────────────────────────────────────────
70
+ def profile_path(name: str) -> Path | None:
71
+ return _resolve(Path("profiles") / f"{name}.yaml", f"profiles/{name}.yaml")
72
+
73
+
74
+ def available_profiles() -> list[str]:
75
+ names: set[str] = set()
76
+ local = Path("profiles")
77
+ if local.is_dir():
78
+ names |= {p.stem for p in local.glob("*.yaml")}
79
+ packaged = DATA_DIR / "profiles"
80
+ if packaged.is_dir():
81
+ names |= {p.stem for p in packaged.glob("*.yaml")}
82
+ return sorted(names)
83
+
84
+
85
+ # ── scaffolding (docassert init) ────────────────────────────────────────────
86
+ _INIT_TREE = ["criteria", "schema", "profiles", "templates", "consistency.yaml"]
87
+
88
+
89
+ def init(dest: str | Path = ".") -> list[str]:
90
+ """Copy the packaged defaults into `dest`, skipping anything already present.
91
+ Returns the top-level names that were created."""
92
+ dest = Path(dest)
93
+ created: list[str] = []
94
+ for name in _INIT_TREE:
95
+ target = dest / name
96
+ if target.exists():
97
+ continue
98
+ src = DATA_DIR / name
99
+ if src.is_dir():
100
+ shutil.copytree(src, target)
101
+ else:
102
+ target.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
103
+ created.append(name)
104
+ return created