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 +8 -0
- pou2md/__main__.py +289 -0
- pou2md/converter.py +253 -0
- pou2md/errors.py +22 -0
- pou2md/manifest.py +74 -0
- pou2md/model/__init__.py +12 -0
- pou2md/model/declaration.py +36 -0
- pou2md/model/flow_graph.py +45 -0
- pou2md/parser/__init__.py +11 -0
- pou2md/parser/cfc_parser.py +244 -0
- pou2md/parser/declaration.py +122 -0
- pou2md/parser/nwl_parser.py +391 -0
- pou2md/parser/st_parser.py +12 -0
- pou2md/renderer/__init__.py +7 -0
- pou2md/renderer/class_diagram.py +34 -0
- pou2md/renderer/flowchart.py +398 -0
- pou2md/watcher.py +357 -0
- pou2md/writer/__init__.py +3 -0
- pou2md/writer/markdown.py +88 -0
- pou2md-0.1.0.dist-info/METADATA +444 -0
- pou2md-0.1.0.dist-info/RECORD +25 -0
- pou2md-0.1.0.dist-info/WHEEL +5 -0
- pou2md-0.1.0.dist-info/entry_points.txt +2 -0
- pou2md-0.1.0.dist-info/licenses/LICENSE +21 -0
- pou2md-0.1.0.dist-info/top_level.txt +1 -0
pou2md/__init__.py
ADDED
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
|
pou2md/model/__init__.py
ADDED
|
@@ -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
|
+
]
|