oct
Loading...
Searching...
No Matches
new_python_file.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/generator/new_python_file.py
4
5"""
6File Generator
7==============
8
9Purpose
10-------
11Create new Option C-compliant Python files with the mandatory 3-line header
12block and a complete module docstring skeleton, ready for immediate development.
13
14Responsibilities
15----------------
16- Accept a target path relative to or absolute from the project root.
17- Write the mandatory 3-line header block (shebang, encoding, file identity).
18- Write the Option C module docstring template next, including the
19 Diagnostics section with a ``Domain`` derived from the file's path and a
20 Dependencies section, so the docstring is the real module docstring.
21- Write the ``from oc_diagnostics import _dbg`` import AFTER the docstring.
22 It used to come first, which made the docstring a bare string statement
23 (found during the chart C runtime trace work, 2026-09-27).
24- Refuse to overwrite existing files unless ``--force`` is explicitly requested.
25
26Diagnostics
27-----------
28Domain: OCT-GENERATOR
29Levels:
30 L2 - lifecycle
31 L3 - semantic details
32 L4 - deep tracing
33
34Contracts
35---------
36- Must not overwrite existing files unless ``force=True``.
37- Generated file must pass the OCT linter header, docstring,
38 diagnostics-profile and dependencies-section checks immediately. It
39 fails ``dbg_usage`` until its first function logs something, which is
40 the intended reminder.
41- Target path may be absolute or relative to the project root.
42
43Dependencies
44------------
45- oct.core.diagnostics (_dbg structured logger)
46- oct.core.project_root (find_project_root, imported but unused; the caller passes project_root)
47- click (ClickException for refused paths and existing files)
48"""
49
50from pathlib import Path
51import click
52from oct.core.project_root import find_project_root
53from oct.core.diagnostics import _dbg
54
55SHEBANG = "#!/usr/bin/env python3"
56ENCODING = "# -*- coding: utf-8 -*-"
57
58# Import block placed after the module docstring (oc_diagnostics is the
59# source of _dbg). Before the docstring it turned the docstring into a bare
60# string statement.
61IMPORT_BLOCK = "\nfrom oc_diagnostics import _dbg\n"
62
63# OCT-LINT: disable=no_hardcoded_secrets reason="Generated-file docstring/skeleton templates are high-entropy but not credentials"
64DOCSTRING_TEMPLATE = '''"""
65Purpose
66-------
67Describe the purpose of this module.
68
69Responsibilities
70----------------
71- List the responsibilities of this module.
72
73Diagnostics
74-----------
75Domain: {domain}
76Levels:
77 L2 - lifecycle
78 L3 - semantic details
79 L4 - deep tracing
80
81Contracts
82---------
83- State the contracts and guarantees of this module.
84
85Dependencies
86------------
87- oc_diagnostics (_dbg structured logger)
88"""
89'''
90
91# OCT-LINT: disable=no_hardcoded_secrets reason="Generated-file docstring/skeleton templates are high-entropy but not credentials"
92TEST_DOCSTRING_TEMPLATE = '''"""
93Purpose
94-------
95Unit tests for {module_name}.
96
97Responsibilities
98----------------
99- Verify the public API of {module_name}.
100
101Diagnostics
102-----------
103Domain: TESTS
104Levels:
105 L2 - lifecycle
106 L3 - semantic details
107 L4 - deep tracing
108
109Contracts
110---------
111- Must not modify external state.
112- Must be deterministic and repeatable.
113"""
114'''
115
116TEST_BODY_TEMPLATE = '''
117{import_line}
118
119
120def test_{stem}_placeholder():
121 """Placeholder test - replace with real tests."""
122 assert True
123'''
124
125
126# Package-marker docstring for generated ``__init__.py`` files. Package
127# markers carry no runtime logic, so - unlike ordinary generated modules -
128# they deliberately OMIT both the ``from oc_diagnostics import _dbg`` import
129# (an unused import trips the ``dbg_usage`` rule) and the Diagnostics
130# docstring section (which implies ``_dbg`` usage), mirroring hand-written
131# package inits such as ``oct/git/__init__.py``.
132PACKAGE_INIT_DOCSTRING = '''"""
133Purpose
134-------
135Package marker for this Python package.
136
137Responsibilities
138----------------
139- Mark this directory as an importable Python package.
140
141Contracts
142---------
143- Contains no runtime logic; created by ``oct new`` as a package marker.
144"""
145'''
146
147
148def _make_header(rel_posix: str) -> str:
149 """Build the 4-line Option C header block (shebang, encoding, identity, blank)."""
150 return f"{SHEBANG}\n{ENCODING}\n# {rel_posix}\n\n"
151
152
153def _make_package_init_content(rel_posix: str) -> str:
154 """Build a header-bearing package-marker ``__init__.py``.
155
156 Plan 151 T6 (oct footgun fix): replaces the previous EMPTY, header-less
157 ``__init__.py`` - which failed the linter's mandatory header rule the
158 moment it was created - with the standard Option C header block plus a
159 minimal module docstring, matching the house style of hand-written
160 package inits.
161 """
162 return _make_header(rel_posix) + PACKAGE_INIT_DOCSTRING
163
164
165def _derive_domain(rel_posix: str) -> str:
166 """The Diagnostics domain for a new module, from its path.
167
168 oct's own convention: the package path, upper-cased and joined with
169 hyphens (``oct/deps/call_graph.py`` -> ``OCT-DEPS``); a module at the
170 project root takes its own name (``script.py`` -> ``SCRIPT``).
171 """
172 parts = rel_posix.split("/")
173 words = parts[:-1] or [parts[-1].rsplit(".", 1)[0]]
174 domain = "-".join(words).upper()
175 return "".join(ch if ch.isalnum() else "-" for ch in domain).strip("-") or "MODULE"
176
177
178def _derive_import_path(rel_posix: str) -> str:
179 """Convert a relative posix path to a dotted Python import path (without .py)."""
180 no_ext = rel_posix.replace(".py", "")
181 return no_ext.replace("/", ".")
182
183
184def _print_content(label: str, content: str) -> None:
185 """Print generated file content with a visual frame."""
186 print(f"\n--- {label} ---")
187 print(content, end="")
188 print("--- end ---")
189
190
192 project_root: Path, path: str, force: bool,
193 dry_run: bool = False, test: bool = False,
194 verbose: bool = False,
195) -> None:
196 func = "create_new_file"
197 _dbg("OCT-GENERATOR", func, "creating a new Option C file", 3)
198 target = Path(path)
199 if not target.is_absolute():
200 target = project_root / target
201
202 if target.suffix != ".py":
203 raise click.ClickException(
204 f"oct new only creates Python (.py) files. Got: '{target.suffix or '(no extension)'}'"
205 )
206
207 # Ensure the target is inside the project root.
208 try:
209 target.resolve().relative_to(project_root.resolve())
210 except ValueError:
211 raise click.ClickException(
212 f"Target path {target} is outside the project root ({project_root}). "
213 "Use a relative path or a path inside the project."
214 )
215
216 if target.exists() and not force:
217 raise click.ClickException(f"File already exists: {target}")
218
219 # Derive test file path
220 test_target = None
221 if test:
222 test_target = project_root / "tests" / f"test_{target.stem}.py"
223 if test_target.exists() and not force:
224 raise click.ClickException(f"Test file already exists: {test_target}")
225
226 # Build content (needed for both dry-run verbose preview and actual write)
227 rel = target.relative_to(project_root).as_posix()
228 header = _make_header(rel)
229 main_content = header + DOCSTRING_TEMPLATE.format(domain=_derive_domain(rel)) + IMPORT_BLOCK
230
231 test_content = None
232 if test_target:
233 test_rel = test_target.relative_to(project_root).as_posix()
234 test_header = _make_header(test_rel)
235 import_path = _derive_import_path(rel)
236 test_docstring = TEST_DOCSTRING_TEMPLATE.format(module_name=target.stem)
237 test_body = TEST_BODY_TEMPLATE.format(
238 import_line=f"from {import_path} import *",
239 stem=target.stem,
240 )
241 test_content = test_header + test_docstring + test_body
242
243 if dry_run:
244 if not target.parent.exists():
245 print(f"[DRY RUN] Would create directory: {target.parent}")
246 # Report __init__.py files that would be created
247 current = target.parent
248 while current != project_root and current != current.parent:
249 try:
250 current.relative_to(project_root)
251 except ValueError:
252 break
253 init_file = current / "__init__.py"
254 if not init_file.exists():
255 print(f"[DRY RUN] Would create package marker: {init_file.relative_to(project_root)}")
256 current = current.parent
257 print(f"[DRY RUN] Would write file: {target}")
258 if test_target:
259 print(f"[DRY RUN] Would write test file: {test_target}")
260 if verbose:
261 _print_content(rel, main_content)
262 if test_content:
263 _print_content(test_rel, test_content)
264 return
265
266 target.parent.mkdir(parents=True, exist_ok=True)
267
268 # Ensure __init__.py exists in all new package directories up to project root
269 current = target.parent
270 while current != project_root and current != current.parent:
271 try:
272 current.relative_to(project_root)
273 except ValueError:
274 break
275 init_file = current / "__init__.py"
276 if not init_file.exists():
277 init_rel = init_file.relative_to(project_root).as_posix()
278 init_file.write_text(
279 _make_package_init_content(init_rel), encoding="utf-8"
280 )
281 print(f" Created package marker: {init_file.relative_to(project_root)}")
282 current = current.parent
283
284 target.write_text(main_content, encoding="utf-8")
285 print(f"Created Option C-compliant file: {target}")
286 if verbose:
287 _print_content(rel, main_content)
288
289 # Generate companion test file
290 if test_target and test_content:
291 test_target.parent.mkdir(parents=True, exist_ok=True)
292 test_target.write_text(test_content, encoding="utf-8")
293 print(f"Created companion test file: {test_target}")
294 if verbose:
295 _print_content(test_rel, test_content)
str _make_package_init_content(str rel_posix)
str _make_header(str rel_posix)
str _derive_import_path(str rel_posix)
str _derive_domain(str rel_posix)
None _print_content(str label, str content)
None create_new_file(Path project_root, str path, bool force, bool dry_run=False, bool test=False, bool verbose=False)