Skip to content

Commit 5fe1d92

Browse files
committed
0.5.0: DLM= preservation, JES2/3 cols 73-80 roundtrip, Python 3.13
DLM= custom delimiter: - DD.instream_dlm field captures retain_delim from the scan tree (normalises /* and EMPTY_DELIM to None, keeps custom values) - CSV serialiser adds instream_dlm column; rejcl reads it back - rejcl emits DATA,DLM='XX' and the custom terminator on reverse path - JSON/YAML include instream_dlm via asdict() when non-null - Golden files updated for inlinedd.jcl which uses !! and zz delimiters JES2/3 cols 73-80 roundtrip: - _scan_jes2_control now calls _add_scanned_line so the tail (cols 72+) is captured; _reconstruct_jes2 appends tail[1:] (the 8-char sequence numbers) instead of padding with spaces - _scan_jes3_control non-DATASET uses comment_text scan line matching the _scan_comment_stmt approach, fixing both roundtrip and emit paths Quality: - Python 3.13 classifier added; CI matrix extended to test both 3.12 and 3.13 - CLI smoke tests cover empty-bytes stdin warning and empty-file warning - Regression tests cover instream_dlm model capture and JSON roundtrip - CHANGELOG 0.5.0 entry covers all changes in this cycle
1 parent d5b509d commit 5fe1d92

14 files changed

Lines changed: 117 additions & 16 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ on:
99
jobs:
1010
check:
1111
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.12", "3.13"]
1215
steps:
1316
- uses: actions/checkout@v4
1417

1518
- name: Install uv
1619
uses: astral-sh/setup-uv@v5
1720
with:
18-
python-version: "3.12"
21+
python-version: ${{ matrix.python-version }}
1922

2023
- name: Sync dependencies
2124
run: uv sync --all-groups

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# Changelog
22

3+
## 0.5.0 (unreleased)
4+
5+
### Added
6+
7+
- **`DD.instream_dlm`** field on the `DD` model: custom `DLM=` delimiters
8+
are now preserved through the forward pass. JSON, YAML, and CSV
9+
serialisers include the value; the rejcl reverse path emits `DATA,DLM='XX'`
10+
and uses the custom terminator when reconstructing JCL.
11+
- Python 3.13 classifier and CI test matrix entry.
12+
13+
### Fixed
14+
15+
- **Scanner: JES2/3 cols 73-80 sequence numbers** on `/*` control statements
16+
(e.g., `/*JOBPARM`, `/*SETUP`) were dropped during roundtrip. The scanner
17+
now captures the tail, matching the `//` and `//*` behaviour.
18+
- **CLI: empty or comment-only JCL** now emits a warning to stderr with a
19+
preview of the input (up to 5 lines) and exits 0 rather than silently
20+
producing empty output.
21+
- **CLI: bare Python tracebacks** from I/O errors, bad paths, and serialiser
22+
failures now surface as clean `fromjcl: <reason>` messages on stderr.
23+
324
## 0.4.0 (2026-05-23)
425

526
### Added

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ classifiers = [
1818
"Operating System :: POSIX",
1919
"Programming Language :: Python :: 3",
2020
"Programming Language :: Python :: 3.12",
21+
"Programming Language :: Python :: 3.13",
2122
"Topic :: Software Development :: Code Generators",
2223
"Topic :: System :: Systems Administration",
2324
"Topic :: Text Processing",
@@ -124,6 +125,7 @@ ignore_names = [
124125
"src_dataset",
125126
"region", "account", "programmer", "class_",
126127
"msgclass", "msglevel", "notify",
128+
"instream_dlm",
127129
]
128130

129131
# --- Interrogate ---

src/fromjcl/_scanner.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -599,22 +599,28 @@ def _scan_comment_stmt(self, text: str) -> None:
599599
)
600600

601601
def _scan_jes2_control(self, text: str) -> None:
602-
# TODO: cols 73-80 sequence numbers are dropped; // and //* preserve them.
603602
self._add_stmt("/*", None)
604603
body_len = JCL_TXTLEN - PREFIX_LEN + 1
605604
key = text[PREFIX_LEN : PREFIX_LEN + body_len]
606605
self._add_kvp(key, None, None, True)
606+
# Minimal scan line so _reconstruct_jes2 can recover cols 73-80 via tail.
607+
self._add_scanned_line(None, None)
607608

608609
def _scan_jes3_control(self, text: str, column: int) -> None:
609-
# TODO: same cols 73-80 limitation as _scan_jes2_control above.
610610
self._add_stmt("//*", None)
611611
body = text[column:]
612612
if _word_starts(body, "DATASET"):
613613
self.state = ScanState.ContinueJES3Dataset
614614
else:
615-
body_len = JCL_TXTLEN - PREFIX_LEN + 1
616-
key = text[PREFIX_LEN : PREFIX_LEN + body_len]
617-
self._add_kvp(key, None, None, True)
615+
raw_len = len(self._current_raw)
616+
content_end = max(column, min(raw_len, JCL_RECLEN))
617+
comment_text = self._current_raw[column:content_end] or None
618+
self._add_scanned_line(
619+
None,
620+
comment_text,
621+
comment_col=column,
622+
comment_end_col=column + len(comment_text or ""),
623+
)
618624

619625
def _dispatch(
620626
self,

src/fromjcl/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ class DD:
193193
sysout: str | None = None
194194
dummy: bool = False
195195
instream: str | None = None
196+
instream_dlm: str | None = None
196197

197198
@classmethod
198199
def from_statements(cls, stmts: list[dict[str, Any]]) -> "DD":
@@ -206,6 +207,9 @@ def from_statements(cls, stmts: list[dict[str, Any]]) -> "DD":
206207
inst = first.get("instream")
207208
instream = inst.get("bytes") if isinstance(inst, dict) else inst
208209
is_instream_dd = instream is not None
210+
dlm = inst.get("retain_delim") if isinstance(inst, dict) else None
211+
if not dlm or dlm in ("/*", " "):
212+
dlm = None
209213

210214
for p in params:
211215
key = p["key"].upper() if p["key"] else ""
@@ -221,7 +225,7 @@ def from_statements(cls, stmts: list[dict[str, Any]]) -> "DD":
221225
if dummy:
222226
return cls(name=name, dummy=True)
223227
if is_instream_dd:
224-
return cls(name=name, instream=instream or "")
228+
return cls(name=name, instream=instream or "", instream_dlm=dlm)
225229

226230
datasets = []
227231
for stmt in stmts:

src/fromjcl/rejcl.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ def _job_dict_from_csv(text: str) -> dict[str, Any]:
107107
elif row.get("instream"):
108108
# Inverse of serialize/csv.py:_format_instream.
109109
dd["instream"] = row["instream"].replace("\\n", "\n")
110+
if row.get("instream_dlm"):
111+
dd["instream_dlm"] = row["instream_dlm"]
110112
else:
111113
dd["datasets"] = []
112114
step["dds"].append(dd)
@@ -237,13 +239,17 @@ def _dd_statements(dd: dict[str, Any]) -> list[dict[str, Any]]:
237239
}
238240
]
239241
if dd.get("instream") is not None:
240-
# TODO: hardcoded `/*`. Custom DLM= is dropped on the forward pass.
242+
dlm = dd.get("instream_dlm") or None
243+
if dlm:
244+
params = [{"key": "DATA", "value": None}, {"key": "DLM", "value": f"'{dlm}'"}]
245+
else:
246+
params = [{"key": "*", "value": None}]
241247
return [
242248
{
243249
"type": "DD",
244250
"name": name,
245-
"parameters": [{"key": "*", "value": None}],
246-
"instream": {"bytes": dd["instream"], "retain_delim": "/*"},
251+
"parameters": params,
252+
"instream": {"bytes": dd["instream"], "retain_delim": dlm or "/*"},
247253
}
248254
]
249255
datasets = dd.get("datasets") or []

src/fromjcl/serialize/csv.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"sysout",
4343
"dummy",
4444
"instream",
45+
"instream_dlm",
4546
]
4647

4748

@@ -76,6 +77,7 @@ def _dd_base(step_base: dict[str, str], dd: DD) -> dict[str, str]:
7677
"sysout": dd.sysout or "",
7778
"dummy": "true" if dd.dummy else "",
7879
"instream": _format_instream(dd.instream),
80+
"instream_dlm": dd.instream_dlm or "",
7981
}
8082

8183

src/fromjcl/serialize/jcl.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,12 @@ def _reconstruct_jes2(stmt: dict[str, Any], target_len: int) -> str:
371371
# The scanner stores a 70-char body window; trim if the source record was shorter.
372372
if target_len and target_len < len(line):
373373
line = line[:target_len]
374+
scan_lines = stmt.get("scanned_lines") or []
375+
tail = (scan_lines[0].get("tail") or "") if scan_lines else ""
376+
# body ends at col 72 (JCL_TXTLEN); tail starts at the same column,
377+
# so tail[1:] (cols 73-80) are the sequence numbers to restore.
378+
if len(tail) > 1:
379+
return line + tail[1:]
374380
return _pad_to(line, target_len)
375381

376382

tests/jcl_samples/parser_edge_cases/SOURCES.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ The following files are hand-authored originals written for this project:
2222

2323
| File | Notes |
2424
| ------------------------- | ----- |
25-
| `lowercase_jobname.jcl` | Jobname containing lowercase characters (template placeholder pattern); exercises the name-char validation fix in 0.4.0 |
26-
| `acct_multi_element.jcl` | JOB card with a parenthesised multi-element account field `(ACCT001,BIN1,BLDG2,DEPT3)`; exercises the paren-nesting fix in 0.4.0 |
25+
| `lowercase_jobname.jcl` | Jobname containing lowercase characters (template placeholder pattern); exercises the name-char validation fix in 0.4.0 |
26+
| `acct_multi_element.jcl` | JOB card with a parenthesised multi-element account field `(ACCT001,BIN1,BLDG2,DEPT3)`; exercises the paren-nesting fix in 0.4.0 |
27+
| `dlm_custom_delimiter.jcl` | `DD DATA,DLM='@@'` with a custom instream terminator; exercises `instream_dlm` capture and JSON/YAML/CSV rejcl roundtrip |
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//DLMTEST JOB CLASS=A,MSGCLASS=A
2+
//STEP1 EXEC PGM=IEFBR14
3+
//SYSIN DD DATA,DLM='@@'
4+
HELLO FROM INSTREAM
5+
ANOTHER RECORD
6+
@@
7+
//

0 commit comments

Comments
 (0)