pou2md 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.
pou2md/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ POU to Markdown Converter
3
+
4
+ Converts TwinCAT 3 .TcPOU files into Mermaid class diagrams and flowcharts
5
+ embedded in Markdown files.
6
+ """
7
+
8
+ __version__ = "0.1.0"
pou2md/__main__.py ADDED
@@ -0,0 +1,289 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import sys
7
+ from importlib.metadata import PackageNotFoundError, version
8
+ from pathlib import Path
9
+ try:
10
+ import tomllib
11
+ except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback
12
+ import tomli as tomllib
13
+
14
+ from .converter import TcPOUConverter
15
+ from .manifest import clean_stale_outputs, manifest_path, save_manifest
16
+ from .errors import TcPOUError
17
+
18
+
19
+ class _JsonFormatter(logging.Formatter):
20
+ def format(self, record: logging.LogRecord) -> str:
21
+ return json.dumps({
22
+ "level": record.levelname,
23
+ "message": record.getMessage(),
24
+ "logger": record.name,
25
+ })
26
+
27
+
28
+ def main() -> int:
29
+ parser = argparse.ArgumentParser(
30
+ prog="python -m pou2md",
31
+ description="Convert TwinCAT 3 .TcPOU files into Mermaid diagrams in Markdown.",
32
+ )
33
+ parser.add_argument(
34
+ "input",
35
+ nargs="?",
36
+ default=".",
37
+ help="Path to a .TcPOU file or directory containing .TcPOU files (default: .).",
38
+ )
39
+ parser.add_argument("--config", type=Path, help="Load defaults from a TOML configuration file.")
40
+ parser.add_argument("--version", action="version", version=_package_version())
41
+ parser.add_argument(
42
+ "-o",
43
+ "--output",
44
+ help="Output directory or output file path (e.g. -o output/ or -o unified.md).",
45
+ )
46
+ parser.add_argument(
47
+ "-r",
48
+ "--recursive",
49
+ action="store_true",
50
+ help="Recursively scan subdirectories for .TcPOU files.",
51
+ )
52
+ parser.add_argument(
53
+ "-u",
54
+ "--unified",
55
+ action="store_true",
56
+ help="Save all converted POUs into a single Markdown file.",
57
+ )
58
+ parser.add_argument(
59
+ "--single-folder",
60
+ action="store_true",
61
+ help="Also save individual Markdown files (the default unless --unified is used alone).",
62
+ )
63
+ parser.add_argument(
64
+ "--copy-folder-structure",
65
+ action="store_true",
66
+ help="Save individual Markdown files using the input folder structure instead of flattened names.",
67
+ )
68
+ parser.add_argument(
69
+ "--exclude",
70
+ action="append",
71
+ default=[],
72
+ metavar="PATTERN",
73
+ help="Exclude matching filename or relative path pattern; may be repeated.",
74
+ )
75
+ parser.add_argument("--poll-interval", type=float, default=1.0, help="Watcher polling interval in seconds.")
76
+ parser.add_argument("--debounce-delay", type=float, default=0.25, help="Delay after a detected change in seconds.")
77
+ parser.add_argument("--retry-count", type=int, default=3, help="Conversion attempts after a transient save failure.")
78
+ parser.add_argument("--retry-delay", type=float, default=0.5, help="Delay between conversion attempts in seconds.")
79
+ parser.add_argument("-v", "--verbose", action="store_true", help="Enable diagnostic logging.")
80
+ parser.add_argument("--quiet", action="store_true", help="Suppress normal status messages.")
81
+ parser.add_argument("--log-format", choices=("text", "json"), default="text", help="Logging output format.")
82
+ parser.add_argument("--dry-run", action="store_true", help="Show planned outputs without writing files.")
83
+ parser.add_argument("--clean", action="store_true", help="Remove stale outputs recorded by the manifest.")
84
+ parser.add_argument(
85
+ "--stdout",
86
+ action="store_true",
87
+ help="Print Markdown output to stdout instead of saving to a file.",
88
+ )
89
+ parser.add_argument(
90
+ "--curve",
91
+ choices=["step", "stepAfter", "linear", "basis", "none"],
92
+ default="step",
93
+ help="Mermaid flowchart curve style (default: 'step' for straight orthogonal lines with horizontal node entry).",
94
+ )
95
+ parser.add_argument(
96
+ "--no-bifurcate",
97
+ dest="bifurcate",
98
+ action="store_false",
99
+ default=True,
100
+ help="Disable bifurcation junction nodes (draw separate wires instead of a branching junction).",
101
+ )
102
+ parser.add_argument(
103
+ "--repeat-inputs",
104
+ action="store_true",
105
+ default=False,
106
+ help="Repeat input variable blocks per connection in CFC (avoids long cross-diagram wires).",
107
+ )
108
+ parser.add_argument(
109
+ "-w",
110
+ "--watch",
111
+ action="store_true",
112
+ help="Watch input file or directory for changes and automatically re-convert.",
113
+ )
114
+
115
+ preliminary = argparse.ArgumentParser(add_help=False)
116
+ preliminary.add_argument("--config", type=Path)
117
+ preliminary_args, _ = preliminary.parse_known_args()
118
+ if preliminary_args.config:
119
+ try:
120
+ config = tomllib.loads(preliminary_args.config.read_text(encoding="utf-8"))
121
+ except (OSError, tomllib.TOMLDecodeError) as error:
122
+ parser.error(f"could not load config '{preliminary_args.config}': {error}")
123
+ parser.set_defaults(**_config_defaults(config))
124
+
125
+ args = parser.parse_args()
126
+ input_path = Path(args.input)
127
+
128
+ if args.quiet and args.verbose:
129
+ parser.error("--quiet and --verbose cannot be used together")
130
+ if args.poll_interval <= 0 or args.debounce_delay < 0 or args.retry_count < 1 or args.retry_delay < 0:
131
+ parser.error("watch timing values must be positive (debounce/retry delays may be zero)")
132
+ if input_path.is_dir() and args.copy_folder_structure and not args.recursive:
133
+ parser.error("--copy-folder-structure requires --recursive for directory input")
134
+ if input_path.is_dir() and args.stdout and not args.unified:
135
+ parser.error("--stdout for directory input requires --unified")
136
+ if args.clean and not args.output:
137
+ parser.error("--clean requires --output")
138
+ if args.clean and not input_path.is_dir():
139
+ parser.error("--clean requires directory input")
140
+ if args.dry_run and args.watch:
141
+ parser.error("--dry-run cannot be combined with --watch")
142
+
143
+ handler = logging.StreamHandler()
144
+ handler.setFormatter(_JsonFormatter() if args.log_format == "json" else logging.Formatter("[%(levelname)s] %(message)s"))
145
+ logging.basicConfig(
146
+ level=logging.DEBUG if args.verbose else (logging.ERROR if args.quiet else logging.INFO),
147
+ handlers=[handler],
148
+ )
149
+
150
+ if not input_path.exists():
151
+ print(f"Error: Path '{input_path}' does not exist.", file=sys.stderr)
152
+ return 1
153
+ validation_error = _validate_paths(input_path, args)
154
+ if validation_error:
155
+ parser.error(validation_error)
156
+
157
+ curve_setting = None if args.curve == "none" else args.curve
158
+ converter = TcPOUConverter(
159
+ curve=curve_setting,
160
+ bifurcate=args.bifurcate,
161
+ repeat_inputs=args.repeat_inputs,
162
+ )
163
+
164
+ if args.watch:
165
+ if input_path.is_dir() and not args.output and not args.stdout:
166
+ print(
167
+ "Error: Please specify an output target with -o / --output, or use --stdout.",
168
+ file=sys.stderr,
169
+ )
170
+ return 1
171
+ from .watcher import start_watcher
172
+
173
+ return start_watcher(
174
+ input_path=input_path,
175
+ converter=converter,
176
+ output=args.output,
177
+ recursive=args.recursive,
178
+ unified=args.unified,
179
+ single_folder=(not args.unified) or args.single_folder or args.copy_folder_structure,
180
+ copy_folder_structure=args.copy_folder_structure,
181
+ to_stdout=args.stdout or (input_path.is_file() and not args.output),
182
+ poll_interval=args.poll_interval,
183
+ debounce_delay=args.debounce_delay,
184
+ retry_count=args.retry_count,
185
+ retry_delay=args.retry_delay,
186
+ exclude=tuple(args.exclude),
187
+ quiet=args.quiet,
188
+ )
189
+
190
+ try:
191
+ if input_path.is_file():
192
+ md_text, out_file = converter.convert_file(
193
+ input_path,
194
+ output_dir=args.output if not args.stdout else None,
195
+ )
196
+ elif input_path.is_dir():
197
+ if not args.output and not args.stdout:
198
+ parser.error("Please specify an output target with -o / --output, or use --stdout.")
199
+
200
+ written, unified_text = converter.convert_directory(
201
+ input_dir=input_path,
202
+ output_dir=args.output if not args.stdout else None,
203
+ recursive=args.recursive,
204
+ unified=args.unified,
205
+ single_folder=(not args.unified) or args.single_folder or args.copy_folder_structure,
206
+ copy_folder_structure=args.copy_folder_structure,
207
+ exclude=tuple(args.exclude),
208
+ dry_run=args.dry_run,
209
+ )
210
+
211
+ if args.output:
212
+ manifest = manifest_path(args.output)
213
+ current_outputs = set(written)
214
+ stale = clean_stale_outputs(manifest, current_outputs, dry_run=args.dry_run) if args.clean else []
215
+ if not args.dry_run:
216
+ save_manifest(manifest, current_outputs, metadata=_manifest_metadata(args))
217
+ else:
218
+ stale = []
219
+
220
+ if args.dry_run:
221
+ for path in written:
222
+ print(f"Would generate: {path}")
223
+ for path in stale:
224
+ print(f"Would remove: {path}")
225
+ elif args.stdout or (args.unified and not args.output):
226
+ if hasattr(sys.stdout, "reconfigure"):
227
+ sys.stdout.reconfigure(encoding="utf-8")
228
+ if unified_text:
229
+ print(unified_text)
230
+ elif not args.quiet:
231
+ print(f"Generated {len(written)} output file(s).")
232
+ return 0
233
+ except TcPOUError as error:
234
+ logging.getLogger(__name__).error("%s", error)
235
+ return 1
236
+ if args.stdout or not args.output:
237
+ if hasattr(sys.stdout, "reconfigure"):
238
+ sys.stdout.reconfigure(encoding="utf-8")
239
+ print(md_text)
240
+ elif not args.quiet:
241
+ print(f"Generated: {out_file}")
242
+
243
+ return 0
244
+
245
+
246
+ def _package_version() -> str:
247
+ try:
248
+ return version("pou2md")
249
+ except PackageNotFoundError:
250
+ return "0.1.0"
251
+
252
+
253
+ def _config_defaults(config: dict[str, object]) -> dict[str, object]:
254
+ allowed = {
255
+ "input", "output", "recursive", "unified", "single_folder",
256
+ "copy_folder_structure", "exclude", "poll_interval", "debounce_delay",
257
+ "retry_count", "retry_delay", "verbose", "quiet", "log_format",
258
+ "dry_run", "clean", "stdout", "curve", "bifurcate", "repeat_inputs", "watch",
259
+ }
260
+ return {key: value for key, value in config.items() if key in allowed}
261
+
262
+
263
+ def _manifest_metadata(args: argparse.Namespace) -> dict[str, object]:
264
+ return {
265
+ "version": _package_version(),
266
+ "recursive": args.recursive,
267
+ "unified": args.unified,
268
+ "single_folder": (not args.unified) or args.single_folder or args.copy_folder_structure,
269
+ "copy_folder_structure": args.copy_folder_structure,
270
+ "exclude": args.exclude,
271
+ }
272
+
273
+
274
+ def _validate_paths(input_path: Path, args: argparse.Namespace) -> str | None:
275
+ if not args.output:
276
+ return None
277
+ output = Path(args.output).resolve()
278
+ source = input_path.resolve()
279
+ if input_path.is_file() and output == source:
280
+ return "output path cannot overwrite the input .TcPOU file"
281
+ if input_path.is_dir() and output == source:
282
+ return "output directory cannot be the input directory"
283
+ if input_path.is_dir() and source in output.parents:
284
+ return "output directory cannot be inside the input directory"
285
+ return None
286
+
287
+
288
+ if __name__ == "__main__":
289
+ sys.exit(main())
pou2md/converter.py ADDED
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import logging
5
+ import os
6
+ import time
7
+ import xml.etree.ElementTree as ET
8
+ from pathlib import Path
9
+
10
+ from .model.flow_graph import FlowGraph
11
+ from .parser.cfc_parser import CFCParser
12
+ from .parser.declaration import DeclarationParser
13
+ from .parser.nwl_parser import NWLParser
14
+ from .parser.st_parser import STParser
15
+ from .writer.markdown import MarkdownWriter
16
+ from .errors import TcPOUInputError, TcPOUOutputError, TcPOUParseError
17
+
18
+ LOGGER = logging.getLogger(__name__)
19
+
20
+
21
+ def _atomic_write(path: Path, content: str) -> None:
22
+ """Write content through a sibling temporary file, then replace the target."""
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
25
+ try:
26
+ with open(temporary, "w", encoding="utf-8", newline="") as f:
27
+ f.write(content)
28
+ f.flush()
29
+ os.fsync(f.fileno())
30
+ os.replace(temporary, path)
31
+ finally:
32
+ try:
33
+ temporary.unlink(missing_ok=True)
34
+ except OSError:
35
+ LOGGER.debug("Could not remove temporary output %s", temporary, exc_info=True)
36
+
37
+
38
+ def _read_file_with_retry(path: Path, max_retries: int = 3, delay: float = 0.1) -> str:
39
+ """Read a file with retries to handle transient file locks during editor saves."""
40
+ for attempt in range(max_retries):
41
+ try:
42
+ with open(path, "r", encoding="utf-8") as f:
43
+ return f.read()
44
+ except (PermissionError, OSError):
45
+ if attempt == max_retries - 1:
46
+ raise
47
+ time.sleep(delay)
48
+ return ""
49
+
50
+
51
+ class TcPOUConverter:
52
+ """Orchestrator to convert TwinCAT 3 .TcPOU files into Mermaid Markdown documents."""
53
+
54
+ def __init__(
55
+ self,
56
+ curve: str | None = "step",
57
+ bifurcate: bool = True,
58
+ repeat_inputs: bool = False,
59
+ ):
60
+ self.repeat_inputs = repeat_inputs
61
+ self.declaration_parser = DeclarationParser()
62
+ self.nwl_parser = NWLParser()
63
+ self.cfc_parser = CFCParser()
64
+ self.st_parser = STParser()
65
+ self.markdown_writer = MarkdownWriter(curve=curve, bifurcate=bifurcate)
66
+
67
+ def convert_content(self, xml_content: str, filename: str = "POU.TcPOU") -> str:
68
+ """Parse XML string content and return generated Markdown text."""
69
+ try:
70
+ root = ET.fromstring(xml_content)
71
+ except ET.ParseError as error:
72
+ raise TcPOUParseError(filename, error) from error
73
+
74
+ # 1. Declaration
75
+ decl_elem = root.find(".//Declaration")
76
+ decl_text = decl_elem.text if decl_elem is not None else ""
77
+ decl = self.declaration_parser.parse(decl_text or "")
78
+
79
+ # 2. Implementation
80
+ impl_elem = root.find(".//Implementation")
81
+ graph: FlowGraph
82
+
83
+ if impl_elem is not None:
84
+ st_elem = impl_elem.find("./ST")
85
+ nwl_elem = impl_elem.find("./NWL")
86
+ cfc_elem = impl_elem.find("./CFC")
87
+
88
+ if st_elem is not None:
89
+ graph = self.st_parser.parse(st_elem)
90
+ elif nwl_elem is not None:
91
+ graph = self.nwl_parser.parse(nwl_elem)
92
+ elif cfc_elem is not None:
93
+ graph = self.cfc_parser.parse(
94
+ cfc_elem, repeat_inputs=self.repeat_inputs
95
+ )
96
+ else:
97
+ graph = FlowGraph(language="UNKNOWN")
98
+ else:
99
+ graph = FlowGraph(language="NONE")
100
+
101
+ # 3. Generate Markdown
102
+ return self.markdown_writer.generate(decl, graph, filename)
103
+
104
+ def convert_file(
105
+ self,
106
+ file_path: Path | str,
107
+ output_dir: Path | str | None = None,
108
+ custom_filename: str | None = None,
109
+ dry_run: bool = False,
110
+ ) -> tuple[str, Path | None]:
111
+ """Convert a single .TcPOU file, optionally writing to an output directory."""
112
+ path = Path(file_path)
113
+ try:
114
+ content = _read_file_with_retry(path)
115
+ except OSError as error:
116
+ raise TcPOUInputError(f"could not read '{path}': {error}") from error
117
+
118
+ md_output = self.convert_content(content, filename=path.name)
119
+
120
+ out_path: Path | None = None
121
+ if output_dir:
122
+ out_target = Path(output_dir)
123
+ if out_target.suffix.lower() == ".md" and not out_target.is_dir():
124
+ out_path = out_target
125
+ if not dry_run:
126
+ out_path.parent.mkdir(parents=True, exist_ok=True)
127
+ else:
128
+ if not dry_run:
129
+ out_target.mkdir(parents=True, exist_ok=True)
130
+ fname = custom_filename if custom_filename else f"{path.stem}.md"
131
+ out_path = out_target / fname
132
+ if not dry_run:
133
+ out_path.parent.mkdir(parents=True, exist_ok=True)
134
+
135
+ if not dry_run:
136
+ try:
137
+ _atomic_write(out_path, md_output)
138
+ except OSError as error:
139
+ raise TcPOUOutputError(f"could not write '{out_path}': {error}") from error
140
+
141
+ return md_output, out_path
142
+
143
+ def convert_directory(
144
+ self,
145
+ input_dir: Path | str,
146
+ output_dir: Path | str | None = None,
147
+ recursive: bool = False,
148
+ unified: bool = False,
149
+ single_folder: bool | None = None,
150
+ copy_folder_structure: bool = False,
151
+ exclude: tuple[str, ...] = (),
152
+ unified_filename: str = "unified.md",
153
+ dry_run: bool = False,
154
+ ) -> tuple[list[Path], str | None]:
155
+ """
156
+ Convert all .TcPOU files in a directory.
157
+
158
+ Parameters:
159
+ input_dir: Root directory to scan for .TcPOU files.
160
+ output_dir: Destination directory or file path for outputs.
161
+ recursive: If True, search subdirectories.
162
+ unified: If True, combine all converted POUs into a single Markdown document.
163
+ single_folder: If True, write individual Markdown files.
164
+ copy_folder_structure: If True, preserve source subdirectories for individual files.
165
+ exclude: Filename or relative-path patterns to skip.
166
+ unified_filename: Default filename if output_dir is a directory when unified=True.
167
+
168
+ Returns:
169
+ A tuple of (written_file_paths, unified_markdown_text).
170
+ """
171
+ if single_folder is None:
172
+ single_folder = not unified
173
+
174
+ in_dir = Path(input_dir)
175
+ candidates = in_dir.rglob("*") if recursive else in_dir.iterdir()
176
+ pou_files = sorted(
177
+ (
178
+ path for path in candidates
179
+ if path.is_file()
180
+ and path.suffix.lower() == ".tcpou"
181
+ and not _is_excluded(path, in_dir, exclude)
182
+ ),
183
+ key=lambda path: str(path).lower(),
184
+ )
185
+
186
+ written_files: list[Path] = []
187
+ unified_text: str | None = None
188
+
189
+ if unified:
190
+ merged_sections: list[str] = []
191
+ for pou_file in pou_files:
192
+ md_text, _ = self.convert_file(pou_file)
193
+ merged_sections.append(md_text.rstrip())
194
+
195
+ unified_text = "\n\n---\n\n".join(merged_sections) + "\n"
196
+
197
+ if output_dir:
198
+ out_target = Path(output_dir)
199
+ if out_target.suffix.lower() == ".md":
200
+ out_path = out_target
201
+ if not dry_run:
202
+ out_path.parent.mkdir(parents=True, exist_ok=True)
203
+ else:
204
+ if not dry_run:
205
+ out_target.mkdir(parents=True, exist_ok=True)
206
+ out_path = out_target / unified_filename
207
+
208
+ if not dry_run:
209
+ try:
210
+ _atomic_write(out_path, unified_text)
211
+ except OSError as error:
212
+ raise TcPOUOutputError(f"could not write '{out_path}': {error}") from error
213
+ written_files.append(out_path)
214
+
215
+ if single_folder:
216
+ individual_output = Path(output_dir) if output_dir else None
217
+ if individual_output and individual_output.suffix.lower() == ".md":
218
+ individual_output = individual_output.parent
219
+
220
+ individual_files: list[Path] = []
221
+ for pou_file in pou_files:
222
+ if recursive and copy_folder_structure:
223
+ rel = pou_file.relative_to(in_dir)
224
+ custom_filename = str(rel.with_suffix(".md"))
225
+ elif recursive:
226
+ rel = pou_file.relative_to(in_dir)
227
+ parts = list(rel.parent.parts) + [pou_file.stem]
228
+ custom_filename = "--".join(parts) + ".md"
229
+ else:
230
+ custom_filename = f"{pou_file.stem}.md"
231
+
232
+ _, out_path = self.convert_file(
233
+ pou_file,
234
+ output_dir=individual_output,
235
+ custom_filename=custom_filename,
236
+ dry_run=dry_run,
237
+ )
238
+ if out_path:
239
+ individual_files.append(out_path)
240
+
241
+ written_files.extend(individual_files)
242
+
243
+ return written_files, unified_text
244
+
245
+
246
+ def _is_excluded(path: Path, root: Path, patterns: tuple[str, ...]) -> bool:
247
+ relative = path.relative_to(root)
248
+ candidates = (path.name, str(relative), relative.as_posix())
249
+ return any(
250
+ fnmatch.fnmatch(candidate, pattern)
251
+ for pattern in patterns
252
+ for candidate in candidates
253
+ )
pou2md/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class TcPOUError(Exception):
5
+ """Base class for conversion errors that can be shown without a traceback."""
6
+
7
+
8
+ class TcPOUParseError(TcPOUError):
9
+ def __init__(self, filename: str, error: Exception):
10
+ self.filename = filename
11
+ self.original = error
12
+ position = getattr(error, "position", None)
13
+ location = f" at line {position[0]}, column {position[1]}" if position else ""
14
+ super().__init__(f"failed to parse '{filename}'{location}: {error}")
15
+
16
+
17
+ class TcPOUInputError(TcPOUError):
18
+ """Raised when an input file cannot be read."""
19
+
20
+
21
+ class TcPOUOutputError(TcPOUError):
22
+ """Raised when generated output cannot be written."""
pou2md/manifest.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from datetime import datetime, timezone
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+
9
+ MANIFEST_NAME = ".pou2md-manifest.json"
10
+
11
+
12
+ def manifest_path(output: str | Path) -> Path:
13
+ target = Path(output)
14
+ return target.parent / MANIFEST_NAME if target.suffix.lower() == ".md" else target / MANIFEST_NAME
15
+
16
+
17
+ def load_manifest(path: Path) -> set[Path]:
18
+ try:
19
+ data = json.loads(path.read_text(encoding="utf-8"))
20
+ root = path.parent.resolve()
21
+ outputs = set()
22
+ for item in data.get("outputs", []):
23
+ candidate = (path.parent / Path(item)).resolve()
24
+ if candidate == root or root in candidate.parents:
25
+ outputs.add(candidate)
26
+ return outputs
27
+ except (FileNotFoundError, OSError, json.JSONDecodeError, TypeError):
28
+ return set()
29
+
30
+
31
+ def save_manifest(
32
+ path: Path,
33
+ outputs: Iterable[Path],
34
+ dry_run: bool = False,
35
+ metadata: dict[str, object] | None = None,
36
+ ) -> None:
37
+ if dry_run:
38
+ return
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ root = path.parent.resolve()
41
+ relative_outputs = sorted(
42
+ str(output.resolve().relative_to(root))
43
+ for output in outputs
44
+ if output.resolve() != path.resolve() and root in output.resolve().parents
45
+ )
46
+ payload = {
47
+ "version": 2,
48
+ "generated_at": datetime.now(timezone.utc).isoformat(),
49
+ "outputs": relative_outputs,
50
+ "metadata": metadata or {},
51
+ }
52
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
53
+ try:
54
+ temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
55
+ os.replace(temporary, path)
56
+ finally:
57
+ temporary.unlink(missing_ok=True)
58
+
59
+
60
+ def clean_stale_outputs(manifest: Path, current_outputs: set[Path], dry_run: bool = False) -> list[Path]:
61
+ stale = sorted(load_manifest(manifest) - current_outputs)
62
+ if not dry_run:
63
+ for output in stale:
64
+ try:
65
+ output.unlink(missing_ok=True)
66
+ except OSError:
67
+ continue
68
+ save_manifest(manifest, current_outputs)
69
+ for parent in sorted({output.parent for output in stale}, reverse=True):
70
+ try:
71
+ parent.rmdir()
72
+ except OSError:
73
+ pass
74
+ return stale
@@ -0,0 +1,12 @@
1
+ from .declaration import Variable, VarGroup, PouDeclaration
2
+ from .flow_graph import FlowNode, FlowEdge, NetworkDiagram, FlowGraph
3
+
4
+ __all__ = [
5
+ "Variable",
6
+ "VarGroup",
7
+ "PouDeclaration",
8
+ "FlowNode",
9
+ "FlowEdge",
10
+ "NetworkDiagram",
11
+ "FlowGraph",
12
+ ]