pobol 0.1.0__tar.gz

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.
pobol-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,208 @@
1
+ Metadata-Version: 2.3
2
+ Name: pobol
3
+ Version: 0.1.0
4
+ Summary: Call COBOL programs as Python functions — file I/O handled for you
5
+ Author-email: andy@andyreagan.com
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+
9
+ # pobol
10
+
11
+ [![CI](https://github.com/andyreagan/pobol/actions/workflows/ci.yml/badge.svg)](https://github.com/andyreagan/pobol/actions/workflows/ci.yml)
12
+ [![PyPI version](https://img.shields.io/pypi/v/pobol)](https://pypi.org/project/pobol/)
13
+
14
+ Call COBOL programs as Python functions. Like [maturin](https://github.com/PyO3/maturin) for Rust, but for GnuCOBOL.
15
+
16
+ **Motivation:** You're migrating COBOL off a mainframe. The programs do real work you want to keep, but they expect VSAM files, DD names, and JCL — none of which exist on Linux/macOS. pobol wraps `cobc` (GnuCOBOL) so you can:
17
+
18
+ 1. Point it at a `.cbl` file — it parses the source and discovers everything
19
+ 2. Pass Python dicts as input records
20
+ 3. Get Python dicts back from the output files
21
+
22
+ No copybook transcription, no temp-file juggling, no batch scripts.
23
+
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ uv add pobol # or: pip install pobol
28
+ ```
29
+
30
+ ### Zero-config mode (auto-discovery)
31
+
32
+ ```python
33
+ from pobol import load
34
+
35
+ # pobol parses the COBOL source, discovers SELECT/FD clauses,
36
+ # extracts record layouts, strips mainframe artifacts, compiles, and
37
+ # handles all file I/O automatically.
38
+ report = load("customer_report.cob")
39
+
40
+ print(report.file_info)
41
+ # COBOL Program: customer_report.cob
42
+ #
43
+ # INPUTS:
44
+ # INPUT-FILE (45 bytes): IN-CUST-ID, IN-CUST-NAME, IN-BALANCE
45
+ # OUTPUTS:
46
+ # OUTPUT-FILE (54 bytes): OUT-CUST-ID, OUT-CUST-NAME, OUT-BALANCE, OUT-DISCOUNT
47
+
48
+ result = report(input_file=[
49
+ {"IN-CUST-ID": 1, "IN-CUST-NAME": "Alice", "IN-BALANCE": 2500.00},
50
+ {"IN-CUST-ID": 2, "IN-CUST-NAME": "Bob", "IN-BALANCE": 800.00},
51
+ ])
52
+
53
+ for rec in result.output_file:
54
+ print(rec)
55
+ # {'out_cust_id': 1, 'out_cust_name': 'Alice', 'out_balance': 2500.0, 'out_discount': 250.0}
56
+ # {'out_cust_id': 2, 'out_cust_name': 'Bob', 'out_balance': 800.0, 'out_discount': 0.0}
57
+ ```
58
+
59
+ ### Mainframe source — works directly
60
+
61
+ ```python
62
+ # Point at raw mainframe COBOL with sequence numbers, LABEL RECORDS,
63
+ # IBM-370, DD-name assigns — pobol handles it all:
64
+ prog = load("check_disbursement.cbl")
65
+
66
+ print(prog.file_info)
67
+ # Discovers all 6 files, 4 record layouts under one FD, 60+ fields...
68
+ # Strips sequence numbers, rewrites ASSIGN TO for env-var mapping,
69
+ # removes LABEL RECORDS/RECORDING MODE/BLOCK CONTAINS.
70
+ ```
71
+
72
+ ### Explicit copybooks (optional override)
73
+
74
+ ```python
75
+ from pobol import load, parse_copybook
76
+
77
+ input_cb = parse_copybook("""
78
+ 01 INPUT-RECORD.
79
+ 05 CUST-ID PIC 9(6).
80
+ 05 CUST-NAME PIC X(30).
81
+ 05 BALANCE PIC 9(7)V9(2).
82
+ """)
83
+ output_cb = parse_copybook("""
84
+ 01 OUTPUT-RECORD.
85
+ 05 CUST-ID PIC 9(6).
86
+ 05 CUST-NAME PIC X(30).
87
+ 05 BALANCE PIC 9(7)V9(2).
88
+ 05 DISCOUNT PIC 9(7)V9(2).
89
+ """)
90
+
91
+ report = load(
92
+ "customer_report.cob",
93
+ inputs={"INPUT-FILE": input_cb},
94
+ outputs={"OUTPUT-FILE": output_cb},
95
+ )
96
+ ```
97
+
98
+ ## How It Works
99
+
100
+ ```
101
+ ┌──────────────────┐
102
+ Python dict ──▶ Copybook.encode() ──▶ temp file ──▶ │ │
103
+ │ cobc-compiled │
104
+ Python dict ◀── Copybook.decode() ◀── temp file ◀── │ COBOL program │
105
+ │ │
106
+ └──────────────────┘
107
+ ```
108
+
109
+ 1. **Parse source** — discovers SELECT/FD/OPEN clauses, extracts PIC field layouts, detects input vs output files
110
+ 2. **Strip mainframe** — removes sequence numbers (cols 1-6), identification (cols 73-80), LABEL RECORDS, RECORDING MODE, BLOCK CONTAINS, fixes SOURCE-COMPUTER
111
+ 3. **Rewrite assigns** — converts `ASSIGN TO DATAIN` (DD names) to working-storage paths loaded from `DD_*` environment variables
112
+ 4. **Compile** — `cobc -x` compiles to a native executable (cached by content hash)
113
+ 5. **Write inputs** — your Python dicts are encoded as fixed-width records to temp files
114
+ 6. **Run** — the executable runs with `DD_*` env vars pointing to temp files
115
+ 7. **Read outputs** — output temp files are decoded back into Python dicts
116
+
117
+ ## Handling Multiple Record Types
118
+
119
+ Real mainframe COBOL often has multiple 01-level records under one FD (header, detail, trailer). pobol discovers all of them:
120
+
121
+ ```python
122
+ prog = load("check_disbursement.cbl")
123
+
124
+ # See all discovered layouts
125
+ for file_name, layouts in prog.record_layouts.items():
126
+ for rec_name, copybook in layouts.items():
127
+ print(f"{file_name}/{rec_name}: {len(copybook.fields)} fields")
128
+ # TXN-DATA-FILE/TXN-DATA-RECORD: 2 fields
129
+ # TXN-DATA-FILE/TXN-DATA-HDR-RECORD: 5 fields
130
+ # TXN-DATA-FILE/TXN-DATA-DETAIL-RECORD: 60 fields
131
+ # TXN-DATA-FILE/TXN-DATA-TRLR-RECORD: 5 fields
132
+
133
+ # For multi-record files, use raw_files with pre-encoded bytes:
134
+ header_bytes = header_copybook.encode(header_dict)
135
+ detail_bytes = detail_copybook.encode_many(detail_dicts)
136
+ trailer_bytes = trailer_copybook.encode(trailer_dict)
137
+
138
+ result = prog(raw_files={
139
+ "TXN-DATA-FILE": header_bytes + detail_bytes + trailer_bytes,
140
+ })
141
+ ```
142
+
143
+ ## API Reference
144
+
145
+ ### `load(source, **kwargs) → CobolProgram`
146
+
147
+ Compile a COBOL source file and return a callable.
148
+
149
+ | Parameter | Type | Default | Description |
150
+ |-----------|------|---------|-------------|
151
+ | `source` | `str \| Path` | required | Path to .cob/.cbl file |
152
+ | `inputs` | `dict[str, Copybook]` | `None` | Override auto-discovered input layouts |
153
+ | `outputs` | `dict[str, Copybook]` | `None` | Override auto-discovered output layouts |
154
+ | `dialect` | `str` | `None` | GnuCOBOL `-std=` dialect |
155
+ | `extra_flags` | `list[str]` | `None` | Extra `cobc` flags |
156
+ | `strip_mainframe` | `bool` | `True` | Strip mainframe format artifacts |
157
+ | `rewrite_assigns` | `bool` | `True` | Rewrite ASSIGN TO for env-var mapping |
158
+
159
+ ### `CobolProgram.__call__(**kwargs) → CobolResult`
160
+
161
+ | Parameter | Type | Description |
162
+ |-----------|------|-------------|
163
+ | `stdin` | `str` | Data for ACCEPT/stdin |
164
+ | `env` | `dict` | Extra environment variables |
165
+ | `timeout` | `float` | Execution timeout (default 30s) |
166
+ | `raw_files` | `dict[str, bytes]` | Pre-encoded file data by SELECT name |
167
+ | `**file_inputs` | `list[dict]` | Input data as list of dicts, keyed by SELECT name |
168
+
169
+ ### `CobolResult`
170
+
171
+ | Attribute | Type | Description |
172
+ |-----------|------|-------------|
173
+ | `.stdout` | `str` | Program stdout (DISPLAY output) |
174
+ | `.stderr` | `str` | Program stderr |
175
+ | `.return_code` | `int` | Exit code |
176
+ | `.outputs` | `dict[str, list[dict]]` | Decoded output files |
177
+ | `.output_file` | `list[dict]` | Shorthand for `.outputs["OUTPUT-FILE"]` |
178
+
179
+ ### `parse_cobol_source(source, **kwargs) → ParsedSource`
180
+
181
+ Parse without compiling. Returns discovered files, record layouts, and cleaned source.
182
+
183
+ ### Supported PIC Clauses
184
+
185
+ | PIC | Python type | Notes |
186
+ |-----|-------------|-------|
187
+ | `X(n)`, `XX` | `str` | Left-justified, space-padded |
188
+ | `9(n)`, `999` | `int` | Zero-padded |
189
+ | `S9(n)` | `int` | Trailing sign overpunch |
190
+ | `9(n)V9(m)`, `9(5)V99` | `float` | Implied decimal (mixed forms supported) |
191
+ | `S9(n)V9(m)` | `float` | Signed implied decimal |
192
+
193
+ ## Development
194
+
195
+ ```bash
196
+ uv sync
197
+ uv run pytest -v
198
+ uv run python examples/demo.py
199
+ ```
200
+
201
+ ## Prerequisites
202
+
203
+ - **GnuCOBOL** (`cobc`) — `brew install gnucobol` / `apt install gnucobol`
204
+ - **Python 3.11+**
205
+
206
+ ## License
207
+
208
+ MIT
pobol-0.1.0/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # pobol
2
+
3
+ [![CI](https://github.com/andyreagan/pobol/actions/workflows/ci.yml/badge.svg)](https://github.com/andyreagan/pobol/actions/workflows/ci.yml)
4
+ [![PyPI version](https://img.shields.io/pypi/v/pobol)](https://pypi.org/project/pobol/)
5
+
6
+ Call COBOL programs as Python functions. Like [maturin](https://github.com/PyO3/maturin) for Rust, but for GnuCOBOL.
7
+
8
+ **Motivation:** You're migrating COBOL off a mainframe. The programs do real work you want to keep, but they expect VSAM files, DD names, and JCL — none of which exist on Linux/macOS. pobol wraps `cobc` (GnuCOBOL) so you can:
9
+
10
+ 1. Point it at a `.cbl` file — it parses the source and discovers everything
11
+ 2. Pass Python dicts as input records
12
+ 3. Get Python dicts back from the output files
13
+
14
+ No copybook transcription, no temp-file juggling, no batch scripts.
15
+
16
+ ## Quick Start
17
+
18
+ ```bash
19
+ uv add pobol # or: pip install pobol
20
+ ```
21
+
22
+ ### Zero-config mode (auto-discovery)
23
+
24
+ ```python
25
+ from pobol import load
26
+
27
+ # pobol parses the COBOL source, discovers SELECT/FD clauses,
28
+ # extracts record layouts, strips mainframe artifacts, compiles, and
29
+ # handles all file I/O automatically.
30
+ report = load("customer_report.cob")
31
+
32
+ print(report.file_info)
33
+ # COBOL Program: customer_report.cob
34
+ #
35
+ # INPUTS:
36
+ # INPUT-FILE (45 bytes): IN-CUST-ID, IN-CUST-NAME, IN-BALANCE
37
+ # OUTPUTS:
38
+ # OUTPUT-FILE (54 bytes): OUT-CUST-ID, OUT-CUST-NAME, OUT-BALANCE, OUT-DISCOUNT
39
+
40
+ result = report(input_file=[
41
+ {"IN-CUST-ID": 1, "IN-CUST-NAME": "Alice", "IN-BALANCE": 2500.00},
42
+ {"IN-CUST-ID": 2, "IN-CUST-NAME": "Bob", "IN-BALANCE": 800.00},
43
+ ])
44
+
45
+ for rec in result.output_file:
46
+ print(rec)
47
+ # {'out_cust_id': 1, 'out_cust_name': 'Alice', 'out_balance': 2500.0, 'out_discount': 250.0}
48
+ # {'out_cust_id': 2, 'out_cust_name': 'Bob', 'out_balance': 800.0, 'out_discount': 0.0}
49
+ ```
50
+
51
+ ### Mainframe source — works directly
52
+
53
+ ```python
54
+ # Point at raw mainframe COBOL with sequence numbers, LABEL RECORDS,
55
+ # IBM-370, DD-name assigns — pobol handles it all:
56
+ prog = load("check_disbursement.cbl")
57
+
58
+ print(prog.file_info)
59
+ # Discovers all 6 files, 4 record layouts under one FD, 60+ fields...
60
+ # Strips sequence numbers, rewrites ASSIGN TO for env-var mapping,
61
+ # removes LABEL RECORDS/RECORDING MODE/BLOCK CONTAINS.
62
+ ```
63
+
64
+ ### Explicit copybooks (optional override)
65
+
66
+ ```python
67
+ from pobol import load, parse_copybook
68
+
69
+ input_cb = parse_copybook("""
70
+ 01 INPUT-RECORD.
71
+ 05 CUST-ID PIC 9(6).
72
+ 05 CUST-NAME PIC X(30).
73
+ 05 BALANCE PIC 9(7)V9(2).
74
+ """)
75
+ output_cb = parse_copybook("""
76
+ 01 OUTPUT-RECORD.
77
+ 05 CUST-ID PIC 9(6).
78
+ 05 CUST-NAME PIC X(30).
79
+ 05 BALANCE PIC 9(7)V9(2).
80
+ 05 DISCOUNT PIC 9(7)V9(2).
81
+ """)
82
+
83
+ report = load(
84
+ "customer_report.cob",
85
+ inputs={"INPUT-FILE": input_cb},
86
+ outputs={"OUTPUT-FILE": output_cb},
87
+ )
88
+ ```
89
+
90
+ ## How It Works
91
+
92
+ ```
93
+ ┌──────────────────┐
94
+ Python dict ──▶ Copybook.encode() ──▶ temp file ──▶ │ │
95
+ │ cobc-compiled │
96
+ Python dict ◀── Copybook.decode() ◀── temp file ◀── │ COBOL program │
97
+ │ │
98
+ └──────────────────┘
99
+ ```
100
+
101
+ 1. **Parse source** — discovers SELECT/FD/OPEN clauses, extracts PIC field layouts, detects input vs output files
102
+ 2. **Strip mainframe** — removes sequence numbers (cols 1-6), identification (cols 73-80), LABEL RECORDS, RECORDING MODE, BLOCK CONTAINS, fixes SOURCE-COMPUTER
103
+ 3. **Rewrite assigns** — converts `ASSIGN TO DATAIN` (DD names) to working-storage paths loaded from `DD_*` environment variables
104
+ 4. **Compile** — `cobc -x` compiles to a native executable (cached by content hash)
105
+ 5. **Write inputs** — your Python dicts are encoded as fixed-width records to temp files
106
+ 6. **Run** — the executable runs with `DD_*` env vars pointing to temp files
107
+ 7. **Read outputs** — output temp files are decoded back into Python dicts
108
+
109
+ ## Handling Multiple Record Types
110
+
111
+ Real mainframe COBOL often has multiple 01-level records under one FD (header, detail, trailer). pobol discovers all of them:
112
+
113
+ ```python
114
+ prog = load("check_disbursement.cbl")
115
+
116
+ # See all discovered layouts
117
+ for file_name, layouts in prog.record_layouts.items():
118
+ for rec_name, copybook in layouts.items():
119
+ print(f"{file_name}/{rec_name}: {len(copybook.fields)} fields")
120
+ # TXN-DATA-FILE/TXN-DATA-RECORD: 2 fields
121
+ # TXN-DATA-FILE/TXN-DATA-HDR-RECORD: 5 fields
122
+ # TXN-DATA-FILE/TXN-DATA-DETAIL-RECORD: 60 fields
123
+ # TXN-DATA-FILE/TXN-DATA-TRLR-RECORD: 5 fields
124
+
125
+ # For multi-record files, use raw_files with pre-encoded bytes:
126
+ header_bytes = header_copybook.encode(header_dict)
127
+ detail_bytes = detail_copybook.encode_many(detail_dicts)
128
+ trailer_bytes = trailer_copybook.encode(trailer_dict)
129
+
130
+ result = prog(raw_files={
131
+ "TXN-DATA-FILE": header_bytes + detail_bytes + trailer_bytes,
132
+ })
133
+ ```
134
+
135
+ ## API Reference
136
+
137
+ ### `load(source, **kwargs) → CobolProgram`
138
+
139
+ Compile a COBOL source file and return a callable.
140
+
141
+ | Parameter | Type | Default | Description |
142
+ |-----------|------|---------|-------------|
143
+ | `source` | `str \| Path` | required | Path to .cob/.cbl file |
144
+ | `inputs` | `dict[str, Copybook]` | `None` | Override auto-discovered input layouts |
145
+ | `outputs` | `dict[str, Copybook]` | `None` | Override auto-discovered output layouts |
146
+ | `dialect` | `str` | `None` | GnuCOBOL `-std=` dialect |
147
+ | `extra_flags` | `list[str]` | `None` | Extra `cobc` flags |
148
+ | `strip_mainframe` | `bool` | `True` | Strip mainframe format artifacts |
149
+ | `rewrite_assigns` | `bool` | `True` | Rewrite ASSIGN TO for env-var mapping |
150
+
151
+ ### `CobolProgram.__call__(**kwargs) → CobolResult`
152
+
153
+ | Parameter | Type | Description |
154
+ |-----------|------|-------------|
155
+ | `stdin` | `str` | Data for ACCEPT/stdin |
156
+ | `env` | `dict` | Extra environment variables |
157
+ | `timeout` | `float` | Execution timeout (default 30s) |
158
+ | `raw_files` | `dict[str, bytes]` | Pre-encoded file data by SELECT name |
159
+ | `**file_inputs` | `list[dict]` | Input data as list of dicts, keyed by SELECT name |
160
+
161
+ ### `CobolResult`
162
+
163
+ | Attribute | Type | Description |
164
+ |-----------|------|-------------|
165
+ | `.stdout` | `str` | Program stdout (DISPLAY output) |
166
+ | `.stderr` | `str` | Program stderr |
167
+ | `.return_code` | `int` | Exit code |
168
+ | `.outputs` | `dict[str, list[dict]]` | Decoded output files |
169
+ | `.output_file` | `list[dict]` | Shorthand for `.outputs["OUTPUT-FILE"]` |
170
+
171
+ ### `parse_cobol_source(source, **kwargs) → ParsedSource`
172
+
173
+ Parse without compiling. Returns discovered files, record layouts, and cleaned source.
174
+
175
+ ### Supported PIC Clauses
176
+
177
+ | PIC | Python type | Notes |
178
+ |-----|-------------|-------|
179
+ | `X(n)`, `XX` | `str` | Left-justified, space-padded |
180
+ | `9(n)`, `999` | `int` | Zero-padded |
181
+ | `S9(n)` | `int` | Trailing sign overpunch |
182
+ | `9(n)V9(m)`, `9(5)V99` | `float` | Implied decimal (mixed forms supported) |
183
+ | `S9(n)V9(m)` | `float` | Signed implied decimal |
184
+
185
+ ## Development
186
+
187
+ ```bash
188
+ uv sync
189
+ uv run pytest -v
190
+ uv run python examples/demo.py
191
+ ```
192
+
193
+ ## Prerequisites
194
+
195
+ - **GnuCOBOL** (`cobc`) — `brew install gnucobol` / `apt install gnucobol`
196
+ - **Python 3.11+**
197
+
198
+ ## License
199
+
200
+ MIT
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "pobol"
3
+ version = "0.1.0"
4
+ description = "Call COBOL programs as Python functions — file I/O handled for you"
5
+ readme = "README.md"
6
+ authors = [
7
+ { email = "andy@andyreagan.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.9.30,<0.10.0"]
14
+ build-backend = "uv_build"
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "pytest>=8.0",
19
+ ]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
@@ -0,0 +1,25 @@
1
+ """pobol — call COBOL programs as Python functions."""
2
+
3
+ from pobol.program import CobolProgram, load
4
+ from pobol.copybook import Copybook, parse_copybook
5
+ from pobol.source_parser import parse_cobol_source, strip_mainframe_format, ParsedSource
6
+ from pobol.exceptions import (
7
+ CompileError,
8
+ CobolRuntimeError,
9
+ CopybookParseError,
10
+ PyCobolError,
11
+ )
12
+
13
+ __all__ = [
14
+ "CobolProgram",
15
+ "load",
16
+ "Copybook",
17
+ "parse_copybook",
18
+ "parse_cobol_source",
19
+ "strip_mainframe_format",
20
+ "ParsedSource",
21
+ "CompileError",
22
+ "CobolRuntimeError",
23
+ "CopybookParseError",
24
+ "PyCobolError",
25
+ ]
@@ -0,0 +1,79 @@
1
+ """Thin wrapper around the ``cobc`` compiler shipped with GnuCOBOL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from pobol.exceptions import CompileError
12
+
13
+ # Default directory for caching compiled binaries
14
+ _CACHE_DIR = Path(tempfile.gettempdir()) / "pobol_cache"
15
+
16
+
17
+ def _source_hash(source_path: Path) -> str:
18
+ """Return a short content-hash of the source file for cache-busting."""
19
+ h = hashlib.sha256(source_path.read_bytes()).hexdigest()[:16]
20
+ return h
21
+
22
+
23
+ def compile_program(
24
+ source_path: str | Path,
25
+ *,
26
+ output_dir: str | Path | None = None,
27
+ extra_flags: list[str] | None = None,
28
+ dialect: str | None = None,
29
+ force: bool = False,
30
+ ) -> Path:
31
+ """Compile a COBOL source file to an executable using ``cobc -x``.
32
+
33
+ Parameters
34
+ ----------
35
+ source_path:
36
+ Path to the ``.cob`` / ``.cbl`` file.
37
+ output_dir:
38
+ Where to put the binary. Defaults to a temp cache dir.
39
+ extra_flags:
40
+ Additional flags passed to ``cobc``.
41
+ dialect:
42
+ E.g. ``"ibm"``, ``"mf"``, ``"cobol85"`` — passed as ``-std=<dialect>``.
43
+ force:
44
+ Re-compile even if a cached binary exists.
45
+
46
+ Returns
47
+ -------
48
+ Path to the compiled executable.
49
+ """
50
+ source_path = Path(source_path).resolve()
51
+ if not source_path.exists():
52
+ raise FileNotFoundError(f"COBOL source not found: {source_path}")
53
+
54
+ if output_dir is None:
55
+ output_dir = _CACHE_DIR
56
+ output_dir = Path(output_dir)
57
+ output_dir.mkdir(parents=True, exist_ok=True)
58
+
59
+ h = _source_hash(source_path)
60
+ exe_name = f"{source_path.stem}_{h}"
61
+ exe_path = output_dir / exe_name
62
+
63
+ if exe_path.exists() and not force:
64
+ return exe_path
65
+
66
+ cmd: list[str] = ["cobc", "-x", "-o", str(exe_path)]
67
+ if dialect:
68
+ cmd.append(f"-std={dialect}")
69
+ if extra_flags:
70
+ cmd.extend(extra_flags)
71
+ cmd.append(str(source_path))
72
+
73
+ result = subprocess.run(cmd, capture_output=True, text=True)
74
+ if result.returncode != 0:
75
+ raise CompileError(str(source_path), result.returncode, result.stderr)
76
+
77
+ # Ensure executable bit
78
+ exe_path.chmod(exe_path.stat().st_mode | 0o111)
79
+ return exe_path