Option C Tools
Loading...
Searching...
No Matches
oct_scaffold.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/scaffold/oct_scaffold.py
4
5"""
6Purpose
7-------
8OI-512 / FS-518 — project scaffolding. Lifts the scaffold body out of
9``oct/cli.py`` so the CLI becomes a thin dispatcher and so the venv
10integration (FS-503 / FS-504) can layer on a single ``include_venv``
11parameter rather than a second 135-line rewrite.
12
13The public entry point is :func:`run_scaffold`, which returns a
14:class:`ScaffoldResult` describing what was created, skipped, or warned
15about. Callers are expected to render the result themselves — this
16module never touches ``click.echo`` directly.
17
18Responsibilities
19----------------
20- Materialise the standard Option C directory layout under a target path.
21- Write bundled templates: ``.octrc.json``, Sphinx ``conf.py`` /
22 ``index.rst``, the debug-config skeleton, stub READMEs.
23- Optionally create a ``.venv/`` via :mod:`venv` (``include_venv``).
24- Run in dry-run mode where no filesystem state changes.
25
26Diagnostics
27-----------
28Domain: SCAFFOLD
29Levels:
30 L2 — lifecycle: start / end / target path
31 L3 — semantic details: per-template decisions
32 L4 — deep tracing: per-file / per-dir outcomes
33
34Contracts
35---------
36- ``run_scaffold`` must be side-effect-free when ``dry_run=True``.
37- Venv creation must never shell out — uses :class:`venv.EnvBuilder`.
38- The Sphinx template directory is resolved at import time to keep
39 ``run_scaffold`` self-contained.
40
41Dependencies
42------------
43- oct.core.diagnostics (_dbg structured logger)
44- oct.core.option_c_dir (OcStatus, write_oc_status)
45"""
46
47from __future__ import annotations
48
49import datetime
50import json
51import os
52import platform
53import shutil
54import venv
55from dataclasses import dataclass, field
56from pathlib import Path
57
58from oct.core.diagnostics import _dbg
59from oct.core.option_c_dir import OcStatus, write_oc_status
60
61
62# ---------------------------------------------------------------------
63# Template bundle — resolved relative to the installed package.
64# ---------------------------------------------------------------------
65
66_SPHINX_TEMPLATES_DIR: Path = Path(__file__).resolve().parents[1] / "templates" / "sphinx"
67
68
69# ---------------------------------------------------------------------
70# Debug-config skeleton (v2 schema).
71# ---------------------------------------------------------------------
72
73_DEBUG_CONFIG_TEMPLATE: dict = {
74 "_schema_version": "2.0",
75 "DEBUG_LEVELS": {
76 "GENERAL": {"level": 2, "color": "default"}
77 },
78 "global_settings": {
79 "silent_mode": False,
80 "enable_timestamps": True,
81 "enable_colors": True,
82 "include_filename": True,
83 "output_mode": "both",
84 "log_to_file": True,
85 "log_file_path": None,
86 "log_format": "text",
87 "log_rotation": {
88 "max_files": 10,
89 "max_size_mb": 5.0,
90 },
91 "global_debug_level": None,
92 "watch_config": False,
93 "ai_trace_level": False,
94 "redaction_patterns": [],
95 "mode": "dev",
96 },
97 "instrumentation_enabled": False,
98 "http_redact_headers": ["authorization", "cookie", "set-cookie", "x-api-key"],
99}
100
101
102# ---------------------------------------------------------------------
103# Result dataclass
104# ---------------------------------------------------------------------
105
106
107@dataclass
109 """Structured output from :func:`run_scaffold`.
110
111 Attributes
112 ----------
113 created
114 Paths (directories + files) that were created.
115 skipped
116 Paths that already existed and were not overwritten.
117 messages
118 Informational strings the caller may render — warnings, tips,
119 or dry-run descriptions.
120 venv_path
121 The ``.venv/`` that was created, or ``None`` when
122 ``include_venv=False``.
123 """
124
125 created: list[Path] = field(default_factory=list)
126 skipped: list[Path] = field(default_factory=list)
127 messages: list[str] = field(default_factory=list)
128 venv_path: Path | None = None
129
130
131# ---------------------------------------------------------------------
132# File-content helpers
133# ---------------------------------------------------------------------
134
135
136def _architecture_md(name: str) -> str:
137 return (
138 f"# {name} — Architecture\n\n"
139 "## Overview\n\nDescribe the high-level architecture.\n\n"
140 "## Components\n\nList major components and their responsibilities.\n\n"
141 "## Data Flow\n\nDescribe how data flows through the system.\n"
142 )
143
144
145def _run_tests_py(name: str) -> str:
146 return (
147 "#!/usr/bin/env python3\n"
148 "# -*- coding: utf-8 -*-\n"
149 f"# {name}/tests/run_tests.py\n\n"
150 "import subprocess\nimport sys\n\n"
151 "sys.exit(subprocess.call([sys.executable, '-m', 'pytest', '--tb=short', '-q']))\n"
152 )
153
154
155def _tests_readme(name: str) -> str:
156 return (
157 f"# {name} — Tests\n\n"
158 "## Running Tests\n\n"
159 "```bash\npython -m pytest tests/ -v\n```\n\n"
160 "## Test Structure\n\nDescribe the test organization.\n"
161 )
162
163
164def _docs_readme(name: str) -> str:
165 return (
166 f"# {name} — Documentation\n\n"
167 "This directory contains project documentation.\n\n"
168 "## Building\n\n"
169 "```bash\noct docs --sphinx\n```\n\n"
170 "Output will be generated in ``docs/_build/html/``.\n"
171 )
172
173
174def _octrc_json() -> str:
175 """OI-530: build the scaffolded ``.octrc.json``.
176
177 Ships with a live ``linter.profile`` default plus an underscore-prefixed
178 ``_git_example`` stanza. JSON has no comments, so we use the same
179 ``_<name>`` convention that ``_schema_version`` and other sentinels
180 already use elsewhere: :func:`oct.core.octrc.load_octrc_safe` ignores
181 underscore-prefixed top-level keys, so the example is discoverable but
182 inert until the operator renames it.
183 """
184 return json.dumps(
185 {
186 "linter": {"profile": "compact"},
187 "docs": {
188 "sphinx_theme": "sphinx_book_theme",
189 "sphinx_output": "docs/_build",
190 "include_dependency_graphs": True,
191 "include_mermaid_diagrams": True,
192 "autodoc_modules": [],
193 },
194 "_git_example": {
195 "protected_branches": ["main", "master"],
196 "branch_profile_map": {"main": "strict", "dev": "compact"},
197 "require_signed_commits": False,
198 },
199 },
200 indent=2,
201 )
202
203
204# FS-503 / FS-504: default ignore lines for scaffolded projects. ``.venv/``
205# ships in both ``.gitignore`` and ``.octignore`` so a freshly-scaffolded
206# project never commits its venv and the linter never walks into it.
207# FS-539: logs and lint/linter caches live under ``.option_c/`` — those
208# subdirs are ignored so only the canonical config files stay tracked.
209_GITIGNORE_LINES: tuple[str, ...] = (
210 "# Python",
211 "__pycache__/",
212 "*.py[cod]",
213 "*.egg-info/",
214 "",
215 "# Virtual environments",
216 ".venv/",
217 "venv/",
218 "env/",
219 "",
220 "# IDE / OS",
221 ".vscode/",
222 ".idea/",
223 ".DS_Store",
224 "",
225 "# Build output",
226 "build/",
227 "dist/",
228 "docs/_build/",
229 "",
230 "# Option C runtime data (FS-539) — config files stay tracked",
231 ".option_c/logs/",
232 ".option_c/cache/",
233 "",
234 "# Logs (legacy location pre-v0.19.0; kept for projects mid-migration)",
235 "logs/",
236 "*.log",
237 "",
238 "# Secrets (mirror NEVER_EXPORT_FILE_PATTERNS)",
239 ".env",
240 ".env.*",
241 "*.pem",
242 "*.key",
243 "credentials.json",
244 "secrets.json",
245 "secrets.yaml",
246)
247
248
249_OCTIGNORE_LINES: tuple[str, ...] = (
250 "# oct-specific ignores — lint/format/test/deps all consult this file.",
251 ".venv/",
252 "venv/",
253 "env/",
254 "docs/_build/",
255 "build/",
256 "dist/",
257 ".formatter_archive/",
258)
259
260
261# ---------------------------------------------------------------------
262# Public entry point
263# ---------------------------------------------------------------------
264
265
267 name: str,
268 base_dir: Path,
269 *,
270 dry_run: bool = False,
271 include_venv: bool = True,
272) -> ScaffoldResult:
273 """Create a new Option C project under ``base_dir / name``.
274
275 Parameters
276 ----------
277 name
278 Project name (becomes the top-level directory).
279 base_dir
280 Parent directory — the project is created at ``base_dir / name``.
281 dry_run
282 When True, describe what would happen without touching the
283 filesystem.
284 include_venv
285 When True, create a ``.venv/`` at the project root using the
286 stdlib :mod:`venv` module.
287
288 Returns
289 -------
290 ScaffoldResult
291 Structured result. Always returns; never raises for existing
292 files (they are recorded in ``skipped``).
293 """
294 func = "run_scaffold"
295 project_dir = base_dir / name
296 _dbg("SCAFFOLD", func, f"start name={name} dir={project_dir} dry_run={dry_run}", 2)
297
298 result = ScaffoldResult()
299 config_text = json.dumps(_DEBUG_CONFIG_TEMPLATE, indent=2)
300
301 # FS-539: canonical project layout. ``.option_c/`` holds the OCT
302 # runtime state (config, logs, cache, migration status). ``docs/``
303 # and ``tests/`` stay at the project root because they're the
304 # developer-facing surface. ``oc_diagnostics/`` is no longer a
305 # scaffolded dir — its former contents (``debug_config.json``) live
306 # under ``.option_c/`` now.
307 dirs_to_create: list[Path] = [
308 project_dir,
309 project_dir / ".option_c",
310 project_dir / ".option_c" / "logs",
311 project_dir / ".option_c" / "cache",
312 project_dir / "docs",
313 project_dir / "docs" / "_static",
314 project_dir / "docs" / "_build",
315 project_dir / "docs" / "_templates",
316 project_dir / "tests",
317 ]
318
319 files_to_write: list[tuple[Path, str]] = [
320 (project_dir / "docs" / "ARCHITECTURE.md", _architecture_md(name)),
321 (project_dir / "tests" / "run_tests.py", _run_tests_py(name)),
322 (project_dir / "tests" / "Tests_README.md", _tests_readme(name)),
323 # FS-539: debug_config.json moved from oc_diagnostics/ to .option_c/
324 (project_dir / ".option_c" / "debug_config.json", config_text),
325 (project_dir / "docs" / "README.md", _docs_readme(name)),
326 # FS-539: .octrc.json moved from root to .option_c/octrc.json
327 (project_dir / ".option_c" / "octrc.json", _octrc_json()),
328 (project_dir / ".gitignore", "\n".join(_GITIGNORE_LINES) + "\n"),
329 (project_dir / ".octignore", "\n".join(_OCTIGNORE_LINES) + "\n"),
330 ]
331
332 if dry_run:
333 for d in dirs_to_create:
334 result.messages.append(f"[DRY RUN] Would create directory: {d}")
335 for path, _ in files_to_write:
336 result.messages.append(f"[DRY RUN] Would write file: {path}")
337 result.messages.append(
338 f"[DRY RUN] Would write file: {project_dir / '.option_c' / 'oc_status.json'}"
339 )
340 result.messages.append("[DRY RUN] Would copy Sphinx templates to docs/")
341 if include_venv:
342 result.messages.append(
343 f"[DRY RUN] Would create virtualenv: {project_dir / '.venv'}"
344 )
345 result.messages.append("[DRY RUN] Scaffold complete (no files written)")
346 _dbg("SCAFFOLD", func, "dry-run end", 2)
347 return result
348
349 # Create directories (parents first).
350 for d in dirs_to_create:
351 if d.exists():
352 result.skipped.append(d)
353 else:
354 d.mkdir(parents=True, exist_ok=True)
355 result.created.append(d)
356
357 # Write bundled files, never overwriting.
358 for path, content in files_to_write:
359 if path.exists():
360 result.skipped.append(path)
361 continue
362 path.write_text(content, encoding="utf-8")
363 result.created.append(path)
364
365 # FS-539: oc_status.json declares this project compliant at birth.
366 _write_fresh_oc_status(project_dir, result)
367
368 # Executable bit on run_tests.py (POSIX only).
369 if platform.system() != "Windows":
370 run_tests_path = project_dir / "tests" / "run_tests.py"
371 if run_tests_path.exists():
372 os.chmod(str(run_tests_path), 0o755)
373
374 # Sphinx template pack.
375 _copy_sphinx_templates(project_dir, name, result)
376
377 # FS-503 / FS-504: optional venv.
378 if include_venv:
379 _create_venv(project_dir, result)
380
381 _dbg(
382 "SCAFFOLD", func,
383 f"end created={len(result.created)} skipped={len(result.skipped)} "
384 f"venv={'yes' if result.venv_path else 'no'}",
385 2,
386 )
387 return result
388
389
390# ---------------------------------------------------------------------
391# Internal helpers
392# ---------------------------------------------------------------------
393
394
396 project_dir: Path, name: str, result: ScaffoldResult,
397) -> None:
398 """Render and copy the bundled Sphinx template pack into ``docs/``.
399
400 Silently skips (with a ``messages`` note) when the bundled template
401 directory is missing — typical for source checkouts that omit the
402 templates pack.
403 """
404 func = "_copy_sphinx_templates"
405 if not _SPHINX_TEMPLATES_DIR.is_dir():
406 result.messages.append(
407 f" Warning: Sphinx templates not found at {_SPHINX_TEMPLATES_DIR}; skipping."
408 )
409 _dbg("SCAFFOLD", func, "templates missing; skipped", 3)
410 return
411
412 docs_dir = project_dir / "docs"
413 static_dir = docs_dir / "_static"
414
415 # conf.py
416 conf_template = (_SPHINX_TEMPLATES_DIR / "conf.py.template").read_text(
417 encoding="utf-8",
418 )
419 year = datetime.datetime.now().year
420 conf_content = (
421 conf_template
422 .replace("{{PROJECT_NAME}}", name)
423 .replace("{{YEAR}}", str(year))
424 .replace("{{AUTHOR}}", "Option C Dev Team")
425 .replace("{{VERSION}}", "0.1.0")
426 )
427 (docs_dir / "conf.py").write_text(conf_content, encoding="utf-8")
428 result.created.append(docs_dir / "conf.py")
429
430 # index.rst
431 idx_template = (_SPHINX_TEMPLATES_DIR / "index.rst.template").read_text(
432 encoding="utf-8",
433 )
434 title = f"{name} Documentation"
435 underline = "=" * len(title)
436 idx_content = (
437 idx_template
438 .replace("{{PROJECT_NAME}}", name)
439 .replace("{{TITLE_UNDERLINE}}", underline)
440 )
441 (docs_dir / "index.rst").write_text(idx_content, encoding="utf-8")
442 result.created.append(docs_dir / "index.rst")
443
444 # Static files.
445 for static_file in ("custom.css", "Option_C_logo.svg"):
446 src = _SPHINX_TEMPLATES_DIR / "_static" / static_file
447 if src.exists():
448 shutil.copy2(src, static_dir / static_file)
449 result.created.append(static_dir / static_file)
450
451 # Build files.
452 for build_file in ("Makefile", "make.bat"):
453 src = _SPHINX_TEMPLATES_DIR / build_file
454 if src.exists():
455 shutil.copy2(src, docs_dir / build_file)
456 result.created.append(docs_dir / build_file)
457
458 result.messages.append(" Copied Sphinx docs template -> docs/")
459 _dbg("SCAFFOLD", func, "templates copied", 3)
460
461
462def _write_fresh_oc_status(project_dir: Path, result: ScaffoldResult) -> None:
463 """FS-539: emit ``oc_status.json`` for a newly-scaffolded project.
464
465 A fresh scaffold is ``stage="compliant"`` with every pillar enabled —
466 the scaffolder writes the full Option C layout, so every pillar is
467 satisfied on day zero. ``migrated_from`` is an empty dict because the
468 project was born in the ``.option_c/`` layout rather than migrated.
469 """
470 func = "_write_fresh_oc_status"
471 status_path = project_dir / ".option_c" / "oc_status.json"
472 if status_path.exists():
473 result.skipped.append(status_path)
474 return
475
476 try:
477 from importlib.metadata import PackageNotFoundError, version
478 try:
479 oct_version = version("oct")
480 except PackageNotFoundError:
481 oct_version = "unknown"
482 except ImportError:
483 oct_version = "unknown"
484
485 today = datetime.date.today().isoformat()
486 pillars = {
487 name: {"enabled": True, "since": today}
488 for name in ("linter", "diagnostics", "git", "docs", "tests", "scaffold")
489 }
490 status = OcStatus(
491 stage="compliant",
492 pillars=pillars,
493 migrated_from={},
494 oct_version_at_migration=oct_version,
495 )
496 write_oc_status(project_dir, status)
497 result.created.append(status_path)
498 _dbg("SCAFFOLD", func, f"oc_status written stage=compliant version={oct_version}", 3)
499
500
501def _create_venv(project_dir: Path, result: ScaffoldResult) -> None:
502 """Create a ``.venv/`` at the project root using :mod:`venv`.
503
504 Any OS-level failure is downgraded to a ``messages`` warning so the
505 caller can continue — a failed venv should not abort scaffolding.
506 """
507 func = "_create_venv"
508 venv_dir = project_dir / ".venv"
509 if venv_dir.exists():
510 result.skipped.append(venv_dir)
511 result.messages.append(f" .venv/ already exists at {venv_dir}")
512 _dbg("SCAFFOLD", func, "venv already exists; skipped", 3)
513 return
514 try:
515 builder = venv.EnvBuilder(with_pip=True, clear=False, upgrade_deps=False)
516 builder.create(str(venv_dir))
517 except (OSError, RuntimeError) as exc:
518 result.messages.append(f" Warning: venv creation failed: {exc}")
519 _dbg("SCAFFOLD", func, f"venv create failed: {exc}", 1)
520 return
521 result.created.append(venv_dir)
522 result.venv_path = venv_dir
523 result.messages.append(f" Created virtualenv -> {venv_dir}")
524 _dbg("SCAFFOLD", func, f"venv created at {venv_dir}", 3)
525
526
527_CLAUDE_SKILLS_TEMPLATE_DIR: Path = (
528 Path(__file__).resolve().parents[1] / "templates" / "claude_skills"
529)
530
531
533 project_dir: Path, result: ScaffoldResult, *, dry_run: bool = False,
534) -> None:
535 """FS-541: copy the bundled Claude Code skills into the project."""
536 func = "_copy_claude_skills"
537 skills_dir = project_dir / ".claude" / "skills"
538
539 if not _CLAUDE_SKILLS_TEMPLATE_DIR.is_dir():
540 result.messages.append(
541 " Warning: Claude skills templates not found; skipping --with-claude."
542 )
543 _dbg("SCAFFOLD", func, "skills templates missing", 3)
544 return
545
546 if dry_run:
547 result.messages.append(
548 f"[DRY RUN] Would copy Claude skills to {skills_dir}"
549 )
550 return
551
552 skills_dir.mkdir(parents=True, exist_ok=True)
553 for skill_subdir in _CLAUDE_SKILLS_TEMPLATE_DIR.iterdir():
554 if not skill_subdir.is_dir():
555 continue
556 dest_skill = skills_dir / skill_subdir.name
557 dest_skill.mkdir(exist_ok=True)
558 for f in skill_subdir.iterdir():
559 dest_file = dest_skill / f.name
560 if dest_file.exists():
561 result.skipped.append(dest_file)
562 continue
563 shutil.copy2(f, dest_file)
564 result.created.append(dest_file)
565
566 result.messages.append(f" Copied Claude skills -> {skills_dir}")
567 _dbg("SCAFFOLD", func, f"skills copied to {skills_dir}", 3)
None _write_fresh_oc_status(Path project_dir, ScaffoldResult result)
None _copy_claude_skills(Path project_dir, ScaffoldResult result, *, bool dry_run=False)
None _copy_sphinx_templates(Path project_dir, str name, ScaffoldResult result)
str _architecture_md(str name)
ScaffoldResult run_scaffold(str name, Path base_dir, *, bool dry_run=False, bool include_venv=True)
None _create_venv(Path project_dir, ScaffoldResult result)