jcl-parser 2.0.2__tar.gz → 2.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.
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/PKG-INFO +3 -3
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/PKG-INFO +3 -3
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/SOURCES.txt +1 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/__init__.py +17 -2
- jcl_parser-2.1.0/jclparser/_include_origins.py +126 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/_job_builder.py +179 -52
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/_model_factories.py +28 -1
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/_post_parse.py +163 -89
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/_source_scanner.py +109 -38
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/_statement_parser.py +72 -7
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/cli.py +32 -4
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/condition_parser.py +18 -12
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/continuation_handler.py +46 -21
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/dataset_lifecycle.py +68 -31
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/errors.py +13 -7
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/explosion.py +437 -166
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/include_loader.py +173 -92
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/lint/rules.py +6 -6
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/metrics.py +4 -1
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/model.py +16 -4
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/parser.py +80 -24
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/plugins/base.py +7 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/proc_loader.py +18 -6
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/_control.py +6 -3
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/_invoked.py +2 -1
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/base.py +8 -3
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/binder.py +2 -1
- jcl_parser-2.1.0/jclparser/program_profiles/ftp.py +187 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/icetool.py +2 -1
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/sort.py +65 -9
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/rc_analysis.py +60 -7
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/sql_detector.py +102 -12
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/utils.py +69 -3
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/validators.py +135 -76
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/pyproject.toml +3 -3
- jcl_parser-2.0.2/jclparser/program_profiles/ftp.py +0 -59
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/LICENSE +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/README.md +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/dependency_links.txt +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/entry_points.txt +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/requires.txt +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jcl_parser.egg-info/top_level.txt +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/__main__.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/builtin_procs.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/jcl_file_type.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/jes_statements.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/lint/__init__.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/lint/base.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/lint/config.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/lint/runner.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/parameter_ast.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/parameter_validators.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/plugins/__init__.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/profiling.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/__init__.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/adrdssu.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/bpxbatch.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/compilers.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/db2_batch.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/dfsrrc00.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/dsnutilb.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/idcams.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iebcompr.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iebdg.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iebgener.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iebptpch.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iebupdte.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iefbr14.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iehinitt.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iehlist.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/iehprogm.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/ikjeft01.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/irxjcl.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/program_profiles/registry.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/py.typed +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/jclparser/system_symbols.py +0 -0
- {jcl_parser-2.0.2 → jcl_parser-2.1.0}/setup.cfg +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jcl-parser
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
|
|
5
|
-
Author-email:
|
|
5
|
+
Author-email: Samuel Dion <sam94dion@gmail.com>
|
|
6
6
|
License: AGPL-3.0-or-later
|
|
7
7
|
Project-URL: Homepage, https://github.com/landparty/jcl-parser
|
|
8
8
|
Project-URL: Repository, https://github.com/landparty/jcl-parser
|
|
9
|
-
Project-URL: Documentation, https://github.com/landparty/jcl-parser/tree/main
|
|
9
|
+
Project-URL: Documentation, https://github.com/landparty/jcl-parser/tree/main/.okf
|
|
10
10
|
Project-URL: Bug Reports, https://github.com/landparty/jcl-parser/issues
|
|
11
11
|
Keywords: jcl,mainframe,parser,ibm,job-control-language
|
|
12
12
|
Classifier: Development Status :: 5 - Production/Stable
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jcl-parser
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
|
|
5
|
-
Author-email:
|
|
5
|
+
Author-email: Samuel Dion <sam94dion@gmail.com>
|
|
6
6
|
License: AGPL-3.0-or-later
|
|
7
7
|
Project-URL: Homepage, https://github.com/landparty/jcl-parser
|
|
8
8
|
Project-URL: Repository, https://github.com/landparty/jcl-parser
|
|
9
|
-
Project-URL: Documentation, https://github.com/landparty/jcl-parser/tree/main
|
|
9
|
+
Project-URL: Documentation, https://github.com/landparty/jcl-parser/tree/main/.okf
|
|
10
10
|
Project-URL: Bug Reports, https://github.com/landparty/jcl-parser/issues
|
|
11
11
|
Keywords: jcl,mainframe,parser,ibm,job-control-language
|
|
12
12
|
Classifier: Development Status :: 5 - Production/Stable
|
|
@@ -20,7 +20,7 @@ application, for example::
|
|
|
20
20
|
import logging as _logging
|
|
21
21
|
|
|
22
22
|
# Package metadata
|
|
23
|
-
__version__ = "2.0
|
|
23
|
+
__version__ = "2.1.0" # x-release-please-version
|
|
24
24
|
__author__ = "JCL Parser Team"
|
|
25
25
|
__description__ = "Parse IBM JCL scripts into Python objects"
|
|
26
26
|
|
|
@@ -66,7 +66,9 @@ from .include_loader import (
|
|
|
66
66
|
DictMemberLoader,
|
|
67
67
|
IncludeMemberLoader,
|
|
68
68
|
JCLLIBMemberLoader,
|
|
69
|
+
LineOrigin,
|
|
69
70
|
resolve_includes,
|
|
71
|
+
resolve_includes_with_origins,
|
|
70
72
|
)
|
|
71
73
|
from .jcl_file_type import (
|
|
72
74
|
FileType,
|
|
@@ -100,7 +102,16 @@ from .lint import (
|
|
|
100
102
|
to_sarif as lint_to_sarif,
|
|
101
103
|
)
|
|
102
104
|
from .metrics import JobMetrics, compute_metrics
|
|
103
|
-
from .model import
|
|
105
|
+
from .model import (
|
|
106
|
+
ConditionalBlock,
|
|
107
|
+
DDStatement,
|
|
108
|
+
Job,
|
|
109
|
+
OutputStatement,
|
|
110
|
+
Parameter,
|
|
111
|
+
Statement,
|
|
112
|
+
Step,
|
|
113
|
+
iter_execution_steps,
|
|
114
|
+
)
|
|
104
115
|
from .parser import parse_jcl, parse_statement, validate_jcl
|
|
105
116
|
from .plugins import (
|
|
106
117
|
ParserConfig,
|
|
@@ -172,6 +183,8 @@ __all__ = [ # noqa: RUF022 — grouped by API tier, not alphabetically
|
|
|
172
183
|
"Parameter",
|
|
173
184
|
"Statement",
|
|
174
185
|
"OutputStatement",
|
|
186
|
+
"ConditionalBlock",
|
|
187
|
+
"iter_execution_steps",
|
|
175
188
|
"JCLParseError",
|
|
176
189
|
"JCLSyntaxError",
|
|
177
190
|
"JCLValidationError",
|
|
@@ -230,7 +243,9 @@ __all__ = [ # noqa: RUF022 — grouped by API tier, not alphabetically
|
|
|
230
243
|
"IncludeMemberLoader",
|
|
231
244
|
"DictMemberLoader",
|
|
232
245
|
"JCLLIBMemberLoader",
|
|
246
|
+
"LineOrigin",
|
|
233
247
|
"resolve_includes",
|
|
248
|
+
"resolve_includes_with_origins",
|
|
234
249
|
# ── Tier 2: Dataset lifecycle analysis ───────────────────────────────────
|
|
235
250
|
"DatasetEvent",
|
|
236
251
|
"analyze_dataset_lifecycle",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Report INCLUDE-expanded line numbers against the main source (issue #118).
|
|
2
|
+
|
|
3
|
+
``// INCLUDE MEMBER=`` statements are expanded before the source is scanned,
|
|
4
|
+
so every line number the parser computes counts the inlined member lines.
|
|
5
|
+
:class:`IncludeLineMap` translates such a number back: a main-source line to
|
|
6
|
+
its own line, a member line to the line of the ``// INCLUDE`` statement in
|
|
7
|
+
the main source (the outermost one, for a nested INCLUDE). Messages about a
|
|
8
|
+
member line also name the member and the line within it::
|
|
9
|
+
|
|
10
|
+
Line 2 (INCLUDE member COMMON, line 3): EXEC statement missing ...
|
|
11
|
+
|
|
12
|
+
The parse runs on the expanded text unchanged; :func:`_parse_jcl_core
|
|
13
|
+
<jclparser.parser._parse_jcl_core>` applies the translation once, at the end
|
|
14
|
+
(:meth:`IncludeLineMap.relabel_job`) or to the exception that ends the parse
|
|
15
|
+
(:meth:`IncludeLineMap.relabel_error`).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import dataclasses
|
|
21
|
+
import re
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .errors import JCLParseError
|
|
26
|
+
from .include_loader import LineOrigin
|
|
27
|
+
from .model import Job, Procedure
|
|
28
|
+
|
|
29
|
+
_LEADING_LINE = re.compile(r"^Line (\d+)")
|
|
30
|
+
_INNER_LINE = re.compile(r"\bon line (\d+)\b")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class IncludeLineMap:
|
|
34
|
+
"""Line number translation for one INCLUDE-expanded source."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, origins: list[LineOrigin]) -> None:
|
|
37
|
+
self._origins = origins
|
|
38
|
+
|
|
39
|
+
def origin(self, line_number: int) -> LineOrigin | None:
|
|
40
|
+
"""Where expanded line *line_number* came from (None if unknown)."""
|
|
41
|
+
if 1 <= line_number <= len(self._origins):
|
|
42
|
+
return self._origins[line_number - 1]
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def translate(self, line_number: int) -> int:
|
|
46
|
+
"""The main-source line of expanded line *line_number*."""
|
|
47
|
+
origin = self.origin(line_number)
|
|
48
|
+
return origin.line if origin is not None else line_number
|
|
49
|
+
|
|
50
|
+
def note(self, line_number: int) -> str:
|
|
51
|
+
"""``"INCLUDE member M, line k"`` for a member line, else ``""``."""
|
|
52
|
+
origin = self.origin(line_number)
|
|
53
|
+
if origin is None or origin.member is None:
|
|
54
|
+
return ""
|
|
55
|
+
return f"INCLUDE member {origin.member}, line {origin.member_line}"
|
|
56
|
+
|
|
57
|
+
def label(self, line_number: int) -> str:
|
|
58
|
+
"""``"Line N"``, plus the member note for a member line."""
|
|
59
|
+
note = self.note(line_number)
|
|
60
|
+
main_line = self.translate(line_number)
|
|
61
|
+
return f"Line {main_line} ({note})" if note else f"Line {main_line}"
|
|
62
|
+
|
|
63
|
+
def relabel_text(self, text: str) -> str:
|
|
64
|
+
"""Translate the ``Line N`` prefix (and ``on line N``) of a message."""
|
|
65
|
+
text = _LEADING_LINE.sub(lambda m: self.label(int(m.group(1))), text, 1)
|
|
66
|
+
return _INNER_LINE.sub(
|
|
67
|
+
lambda m: f"on line {self.translate(int(m.group(1)))}", text
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def relabel_error(self, exc: JCLParseError) -> None:
|
|
71
|
+
"""Translate the line number of the exception that ended the parse."""
|
|
72
|
+
if exc.line_number <= 0:
|
|
73
|
+
return
|
|
74
|
+
exc.include_note = self.note(exc.line_number)
|
|
75
|
+
exc.line_number = self.translate(exc.line_number)
|
|
76
|
+
exc.args = (exc._format_message(),)
|
|
77
|
+
|
|
78
|
+
def relabel_job(self, job: Job, own_procedures: Iterable[Procedure]) -> None:
|
|
79
|
+
"""Translate every line number on *job* and in its warnings.
|
|
80
|
+
|
|
81
|
+
Only the procedures in *own_procedures* -- the in-stream ones, read
|
|
82
|
+
from this source -- are relabelled: an external or built-in PROC
|
|
83
|
+
carries line numbers of its own library member.
|
|
84
|
+
"""
|
|
85
|
+
own = {id(proc) for proc in own_procedures}
|
|
86
|
+
skip = {id(p) for p in job.procedures if id(p) not in own}
|
|
87
|
+
self._relabel_object(job, skip)
|
|
88
|
+
job.warnings[:] = [self.relabel_text(w) for w in job.warnings]
|
|
89
|
+
job.unresolved_output_refs[:] = [
|
|
90
|
+
(step_name, ref, self.translate(line))
|
|
91
|
+
for step_name, ref, line in job.unresolved_output_refs
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
def _relabel_object(self, obj: Any, seen: set[int]) -> None:
|
|
95
|
+
"""Translate ``line_number`` on *obj* and every model object under it."""
|
|
96
|
+
if id(obj) in seen:
|
|
97
|
+
return
|
|
98
|
+
seen.add(id(obj))
|
|
99
|
+
if isinstance(obj, (list, tuple)):
|
|
100
|
+
for item in obj:
|
|
101
|
+
self._relabel_object(item, seen)
|
|
102
|
+
elif isinstance(obj, dict):
|
|
103
|
+
for item in obj.values():
|
|
104
|
+
self._relabel_object(item, seen)
|
|
105
|
+
elif _is_model_object(obj):
|
|
106
|
+
self._relabel_fields(obj, seen)
|
|
107
|
+
|
|
108
|
+
def _relabel_fields(self, obj: Any, seen: set[int]) -> None:
|
|
109
|
+
line_number = getattr(obj, "line_number", None)
|
|
110
|
+
if isinstance(line_number, int) and line_number > 0:
|
|
111
|
+
try:
|
|
112
|
+
obj.line_number = self.translate(line_number)
|
|
113
|
+
except dataclasses.FrozenInstanceError:
|
|
114
|
+
pass
|
|
115
|
+
for field in dataclasses.fields(obj):
|
|
116
|
+
if not field.name.startswith("_") and field.name != "line_number":
|
|
117
|
+
self._relabel_object(getattr(obj, field.name), seen)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_model_object(obj: Any) -> bool:
|
|
121
|
+
"""A jclparser dataclass instance (not a class, not a foreign type)."""
|
|
122
|
+
return (
|
|
123
|
+
dataclasses.is_dataclass(obj)
|
|
124
|
+
and not isinstance(obj, type)
|
|
125
|
+
and type(obj).__module__.startswith("jclparser")
|
|
126
|
+
)
|
|
@@ -34,6 +34,7 @@ from ._model_factories import (
|
|
|
34
34
|
_create_step_from_statement,
|
|
35
35
|
_exec_has_program_spec,
|
|
36
36
|
_has_unresolved_symbol_ref,
|
|
37
|
+
_resolve_jcllib_library,
|
|
37
38
|
_resolve_set_value,
|
|
38
39
|
)
|
|
39
40
|
from ._post_parse import (
|
|
@@ -59,6 +60,8 @@ from .utils import (
|
|
|
59
60
|
instream_delimiter,
|
|
60
61
|
is_positional_key,
|
|
61
62
|
jcl_value_text,
|
|
63
|
+
jes_xmit_delimiter,
|
|
64
|
+
xmit_delimiter,
|
|
62
65
|
)
|
|
63
66
|
|
|
64
67
|
logger = logging.getLogger(__name__)
|
|
@@ -103,8 +106,8 @@ class JobBuilder:
|
|
|
103
106
|
|
|
104
107
|
# Statement type -> name of its ``_on_<type>`` handler. Every type in
|
|
105
108
|
# ``_STATEMENT_TYPES`` must appear here, even when its handler is a
|
|
106
|
-
# deliberate no-op (NULL, ENDCNTL)
|
|
107
|
-
#
|
|
109
|
+
# deliberate no-op (NULL, ENDCNTL). Any other type -- a custom type
|
|
110
|
+
# returned by a StatementPlugin -- is kept in ``Job.custom_statements``.
|
|
108
111
|
_HANDLERS: ClassVar[dict[str, str]] = {
|
|
109
112
|
"JOB": "_on_job",
|
|
110
113
|
"EXEC": "_on_exec",
|
|
@@ -122,10 +125,12 @@ class JobBuilder:
|
|
|
122
125
|
"JES": "_on_jes",
|
|
123
126
|
"OUTPUT": "_on_output",
|
|
124
127
|
"NULL": "_on_null",
|
|
125
|
-
# No richer model representation for COMMAND / SCHEDULE / CNTL
|
|
128
|
+
# No richer model representation for COMMAND / SCHEDULE / CNTL /
|
|
129
|
+
# NOTIFY (z/OS 2.3).
|
|
126
130
|
"COMMAND": "_on_control",
|
|
127
131
|
"SCHEDULE": "_on_control",
|
|
128
132
|
"CNTL": "_on_control",
|
|
133
|
+
"NOTIFY": "_on_control",
|
|
129
134
|
"ENDCNTL": "_on_endcntl",
|
|
130
135
|
"UNKNOWN": "_on_unknown",
|
|
131
136
|
"XMIT": "_on_xmit",
|
|
@@ -151,8 +156,15 @@ class JobBuilder:
|
|
|
151
156
|
self.pending_procedures: list[Procedure] = []
|
|
152
157
|
self.current_delimiter = "/*"
|
|
153
158
|
self.current_terminates_on_slash = False
|
|
154
|
-
#:
|
|
159
|
+
#: Job-level DDs that appear before the first job-level EXEC (e.g.
|
|
160
|
+
#: ``SYSCHK``/``JOBCAT``); attached to that step, never to a step of an
|
|
161
|
+
#: in-stream PROC defined in between (#103).
|
|
155
162
|
self.orphaned_dds: list[DDStatement] = []
|
|
163
|
+
#: DDs inside a PROC/PEND body before the procedure's first EXEC.
|
|
164
|
+
self.proc_orphaned_dds: list[DDStatement] = []
|
|
165
|
+
#: ``len(conditional_stack)`` when the open PROC began: IF blocks at
|
|
166
|
+
#: or below it belong to the enclosing job, not to the procedure.
|
|
167
|
+
self.proc_cond_depth = 0
|
|
156
168
|
#: True while accumulating JOBLIB concatenation lines
|
|
157
169
|
self.in_joblib_concat = False
|
|
158
170
|
#: Qualified name (``PROCSTEP.DDNAME``) of the override DD the current
|
|
@@ -174,6 +186,10 @@ class JobBuilder:
|
|
|
174
186
|
#: only if some SET symbol is redefined after it was first set.
|
|
175
187
|
self.job_level_steps: list[Step] = []
|
|
176
188
|
self.set_redefined = False
|
|
189
|
+
#: Steps of in-stream / cataloged procedures with the procedure they
|
|
190
|
+
#: belong to; finish() prunes their ``set_symbols`` snapshot the same
|
|
191
|
+
#: way (against the procedure's own final SET table).
|
|
192
|
+
self.procedure_steps: list[tuple[Procedure, Step]] = []
|
|
177
193
|
self.pending_exported: set[str] = set()
|
|
178
194
|
self.pending_jcllib: list[str] = []
|
|
179
195
|
self.pending_includes: list[str] = []
|
|
@@ -181,6 +197,9 @@ class JobBuilder:
|
|
|
181
197
|
self.pending_conditionals: list[ConditionalBlock] = []
|
|
182
198
|
self.pending_steps: list[Step | ConditionalBlock] = []
|
|
183
199
|
self.pending_control: list[Statement] = []
|
|
200
|
+
#: Statements of a type the builder has no handler for (e.g. a custom
|
|
201
|
+
#: type returned by a StatementPlugin) seen before the JOB statement.
|
|
202
|
+
self.pending_custom: list[Statement] = []
|
|
184
203
|
#: The open ``CNTL`` block, whose program control records are collected
|
|
185
204
|
#: verbatim until its ``ENDCNTL``.
|
|
186
205
|
self.current_cntl: Statement | None = None
|
|
@@ -280,13 +299,17 @@ class JobBuilder:
|
|
|
280
299
|
# ------------------------------------------------------------------ #
|
|
281
300
|
|
|
282
301
|
def handle_statement(self, stmt: Statement, line_num: int) -> None:
|
|
283
|
-
"""Record statement warnings, then dispatch to the type handler.
|
|
302
|
+
"""Record statement warnings, then dispatch to the type handler.
|
|
303
|
+
|
|
304
|
+
A statement whose type has no handler in :attr:`_HANDLERS` (e.g. a
|
|
305
|
+
custom type returned by a :class:`~jclparser.plugins.StatementPlugin`)
|
|
306
|
+
is kept verbatim in :attr:`Job.custom_statements` (#130).
|
|
307
|
+
"""
|
|
284
308
|
if stmt.warnings:
|
|
285
309
|
self._warnings_sink().extend(stmt.warnings)
|
|
286
310
|
|
|
287
|
-
handler_name = self._HANDLERS.get(stmt.type)
|
|
288
|
-
|
|
289
|
-
getattr(self, handler_name)(stmt, line_num)
|
|
311
|
+
handler_name = self._HANDLERS.get(stmt.type, "_on_custom")
|
|
312
|
+
getattr(self, handler_name)(stmt, line_num)
|
|
290
313
|
|
|
291
314
|
# ------------------------------------------------------------------ #
|
|
292
315
|
# Per-statement-type handlers
|
|
@@ -315,27 +338,27 @@ class JobBuilder:
|
|
|
315
338
|
**self.job.set_statements,
|
|
316
339
|
}
|
|
317
340
|
self.job_level_steps.append(self.current_step)
|
|
341
|
+
elif self.current_procedure is not None:
|
|
342
|
+
# A SET inside the procedure is in effect from where it is coded,
|
|
343
|
+
# so this step sees the procedure's SET values as they stand now.
|
|
344
|
+
self.current_step.set_symbols = dict(self.current_procedure.set_statements)
|
|
345
|
+
self.procedure_steps.append((self.current_procedure, self.current_step))
|
|
318
346
|
self.in_joblib_concat = False # JOBLIB concatenation ends at first EXEC
|
|
319
347
|
self.concat_override_key = None
|
|
320
348
|
|
|
321
|
-
# Attach
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
# Add step to procedure if we're inside PROC/PEND, otherwise to job
|
|
328
|
-
if self.conditional_stack:
|
|
329
|
-
active_cond, active_branch = self.conditional_stack[-1]
|
|
330
|
-
if active_branch == "THEN":
|
|
331
|
-
active_cond.add_then_step(self.current_step)
|
|
332
|
-
else:
|
|
333
|
-
active_cond.add_else_step(self.current_step)
|
|
334
|
-
elif self.current_procedure is not None:
|
|
335
|
-
self.current_procedure.steps.append(self.current_step)
|
|
349
|
+
# Attach DDs seen before the first EXEC of this scope to this step.
|
|
350
|
+
# Job-level DDs (SYSCHK, JOBCAT, ...) wait for the first job-level
|
|
351
|
+
# EXEC: a step inside an in-stream PROC definition must never pick
|
|
352
|
+
# them up, or every expansion of the PROC would copy them (#103).
|
|
353
|
+
if self.current_procedure is not None:
|
|
354
|
+
pending_dds, self.proc_orphaned_dds = self.proc_orphaned_dds, []
|
|
336
355
|
else:
|
|
337
|
-
|
|
338
|
-
|
|
356
|
+
pending_dds, self.orphaned_dds = self.orphaned_dds, []
|
|
357
|
+
for dd in pending_dds:
|
|
358
|
+
self.current_step.add_dd(dd)
|
|
359
|
+
|
|
360
|
+
# Add step to the open IF branch, the PROC being defined, or the job
|
|
361
|
+
self._attach(self.current_step)
|
|
339
362
|
|
|
340
363
|
def _on_dd(self, stmt: Statement, line_num: int) -> None:
|
|
341
364
|
# Create the DD statement
|
|
@@ -351,9 +374,12 @@ class JobBuilder:
|
|
|
351
374
|
elif not stmt.name and self.in_joblib_concat and self.job is not None:
|
|
352
375
|
# Empty-name DD concatenating onto the JOBLIB group
|
|
353
376
|
self.job.joblib_dds.append(current_dd)
|
|
377
|
+
elif self.current_procedure is not None:
|
|
378
|
+
# DD in a PROC body before its first EXEC
|
|
379
|
+
self.proc_orphaned_dds.append(current_dd)
|
|
354
380
|
else:
|
|
355
381
|
self.in_joblib_concat = False
|
|
356
|
-
#
|
|
382
|
+
# Job-level DD: attached to the next job-level step
|
|
357
383
|
self.orphaned_dds.append(current_dd)
|
|
358
384
|
elif stmt.name and "." in stmt.name:
|
|
359
385
|
# This is a DD override for a procedure step
|
|
@@ -419,14 +445,43 @@ class JobBuilder:
|
|
|
419
445
|
self.job.add_procedure(self.current_procedure)
|
|
420
446
|
else:
|
|
421
447
|
self.pending_procedures.append(self.current_procedure)
|
|
448
|
+
# IF blocks already open belong to the job, not to this procedure.
|
|
449
|
+
self.proc_cond_depth = len(self.conditional_stack)
|
|
422
450
|
# Reset current_step since we're now inside procedure
|
|
423
451
|
self.current_step = None
|
|
452
|
+
self.in_joblib_concat = False
|
|
453
|
+
self.proc_orphaned_dds = []
|
|
424
454
|
|
|
425
455
|
def _on_pend(self, stmt: Statement, line_num: int) -> None:
|
|
426
|
-
# End of procedure definition - stop tracking
|
|
456
|
+
# End of procedure definition - stop tracking. PEND closes the PROC
|
|
457
|
+
# scope: an IF opened in the body and never closed by ENDIF ends here
|
|
458
|
+
# (it is already attached to the procedure), so later job steps are
|
|
459
|
+
# not read into it (#102).
|
|
460
|
+
procedure = self.current_procedure
|
|
461
|
+
if procedure is not None:
|
|
462
|
+
while len(self.conditional_stack) > self.proc_cond_depth:
|
|
463
|
+
cond, _branch = self.conditional_stack.pop()
|
|
464
|
+
self._warnings_sink().append(
|
|
465
|
+
f"Line {cond.line_number}: IF block in PROC {procedure.name} "
|
|
466
|
+
f"has no matching ENDIF; closed at PEND (line {line_num})"
|
|
467
|
+
)
|
|
468
|
+
self._warn_proc_orphaned_dds(procedure, line_num)
|
|
469
|
+
self.proc_orphaned_dds = []
|
|
470
|
+
self.proc_cond_depth = 0
|
|
427
471
|
self.current_procedure = None
|
|
428
472
|
self.current_step = None
|
|
429
473
|
|
|
474
|
+
def _warn_proc_orphaned_dds(self, procedure: Procedure, line_num: int) -> None:
|
|
475
|
+
"""Report DDs of a PROC body that had no EXEC step to attach to."""
|
|
476
|
+
if self.proc_orphaned_dds:
|
|
477
|
+
dd_names = ", ".join(
|
|
478
|
+
dd.name or "(unnamed)" for dd in self.proc_orphaned_dds
|
|
479
|
+
)
|
|
480
|
+
self._warnings_sink().append(
|
|
481
|
+
f"Line {line_num}: PROC {procedure.name} has DD statement(s) "
|
|
482
|
+
f"but no EXEC step to attach them to; discarded: {dd_names}"
|
|
483
|
+
)
|
|
484
|
+
|
|
430
485
|
def _on_set(self, stmt: Statement, line_num: int) -> None:
|
|
431
486
|
# SET symbol=value statement. A SET before the JOB statement (or in a
|
|
432
487
|
# JOB-less member) is buffered rather than dropped; finish() flushes it.
|
|
@@ -498,11 +553,26 @@ class JobBuilder:
|
|
|
498
553
|
order_value = stmt.parameters["ORDER"]
|
|
499
554
|
sink = self.job.jcllib_order if self.job else self.pending_jcllib
|
|
500
555
|
# ORDER parameter might already be parsed as a list
|
|
556
|
+
libs: list[str] = []
|
|
501
557
|
if isinstance(order_value, list):
|
|
502
|
-
|
|
558
|
+
libs = [str(lib) for lib in order_value]
|
|
503
559
|
elif isinstance(order_value, str):
|
|
504
|
-
libs = order_value.strip("()").split(",")
|
|
505
|
-
|
|
560
|
+
libs = [lib.strip() for lib in order_value.strip("()").split(",")]
|
|
561
|
+
# Only the SETs coded before this statement are in effect here.
|
|
562
|
+
symbols = (
|
|
563
|
+
{**self.pending_sets, **self.job.set_statements}
|
|
564
|
+
if self.job
|
|
565
|
+
else self.pending_sets
|
|
566
|
+
)
|
|
567
|
+
for lib in libs:
|
|
568
|
+
resolved = _resolve_jcllib_library(lib, symbols)
|
|
569
|
+
if resolved is None:
|
|
570
|
+
self._warnings_sink().append(
|
|
571
|
+
f"Line {stmt.line_number}: JCLLIB ORDER library {lib} "
|
|
572
|
+
"contains an unresolved symbol; kept as written"
|
|
573
|
+
)
|
|
574
|
+
resolved = lib
|
|
575
|
+
sink.append(resolved)
|
|
506
576
|
|
|
507
577
|
def _on_include(self, stmt: Statement, line_num: int) -> None:
|
|
508
578
|
# INCLUDE MEMBER=name
|
|
@@ -545,20 +615,32 @@ class JobBuilder:
|
|
|
545
615
|
condition = condition[:-5]
|
|
546
616
|
return condition
|
|
547
617
|
|
|
548
|
-
def
|
|
549
|
-
"""
|
|
550
|
-
|
|
551
|
-
|
|
618
|
+
def _innermost_if(self) -> tuple[ConditionalBlock, str] | None:
|
|
619
|
+
"""The innermost IF block open in the current scope, or None.
|
|
620
|
+
|
|
621
|
+
Inside a PROC/PEND body only IF blocks opened in that body count; an
|
|
622
|
+
IF the job opened before the PROC statement stays the job's (#102).
|
|
623
|
+
"""
|
|
624
|
+
floor = self.proc_cond_depth if self.current_procedure is not None else 0
|
|
625
|
+
if len(self.conditional_stack) > floor:
|
|
626
|
+
return self.conditional_stack[-1]
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
def _attach(self, item: Step | ConditionalBlock) -> None:
|
|
630
|
+
"""Place a step or IF block in the open branch, PROC, job or buffer."""
|
|
631
|
+
open_if = self._innermost_if()
|
|
632
|
+
if open_if is not None:
|
|
633
|
+
active_cond, active_branch = open_if
|
|
552
634
|
if active_branch == "THEN":
|
|
553
|
-
active_cond.add_then_step(
|
|
635
|
+
active_cond.add_then_step(item)
|
|
554
636
|
else:
|
|
555
|
-
active_cond.add_else_step(
|
|
637
|
+
active_cond.add_else_step(item)
|
|
556
638
|
elif self.current_procedure is not None:
|
|
557
|
-
self.current_procedure.steps.append(
|
|
639
|
+
self.current_procedure.steps.append(item)
|
|
558
640
|
elif self.job is not None:
|
|
559
|
-
self.job.steps.append(
|
|
641
|
+
self.job.steps.append(item)
|
|
560
642
|
else:
|
|
561
|
-
self.pending_steps.append(
|
|
643
|
+
self.pending_steps.append(item)
|
|
562
644
|
|
|
563
645
|
def _open_if(self, stmt: Statement) -> None:
|
|
564
646
|
"""Handle ``IF``: record a new :class:`ConditionalBlock` and enter it."""
|
|
@@ -572,14 +654,14 @@ class JobBuilder:
|
|
|
572
654
|
self.job.add_conditional(conditional)
|
|
573
655
|
else:
|
|
574
656
|
self.pending_conditionals.append(conditional)
|
|
575
|
-
self.
|
|
657
|
+
self._attach(conditional)
|
|
576
658
|
self.conditional_stack.append((conditional, "THEN"))
|
|
577
659
|
|
|
578
660
|
def _switch_branch(self, branch: str, line_num: int) -> None:
|
|
579
661
|
"""Handle ``THEN`` / ``ELSE``: make *branch* the open IF's active one."""
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
self.conditional_stack
|
|
662
|
+
open_if = self._innermost_if()
|
|
663
|
+
if open_if is not None:
|
|
664
|
+
self.conditional_stack[-1] = (open_if[0], branch)
|
|
583
665
|
else:
|
|
584
666
|
self._warnings_sink().append(
|
|
585
667
|
f"Line {line_num}: {branch} with no matching IF; ignored"
|
|
@@ -587,7 +669,7 @@ class JobBuilder:
|
|
|
587
669
|
|
|
588
670
|
def _close_if(self, line_num: int) -> None:
|
|
589
671
|
"""Handle ``ENDIF``: leave the innermost open IF block."""
|
|
590
|
-
if self.
|
|
672
|
+
if self._innermost_if() is not None:
|
|
591
673
|
self.conditional_stack.pop()
|
|
592
674
|
else:
|
|
593
675
|
self._warnings_sink().append(
|
|
@@ -601,6 +683,13 @@ class JobBuilder:
|
|
|
601
683
|
from .jes_statements import parse_jes_control
|
|
602
684
|
|
|
603
685
|
typed = parse_jes_control(stmt.name, raw_params, stmt.line_number)
|
|
686
|
+
if stmt.name.upper() == "XMIT":
|
|
687
|
+
# The records that follow are a job stream for another node,
|
|
688
|
+
# not JCL of this job; they end at /* or DLM= (issue #117).
|
|
689
|
+
self.current_dd = None
|
|
690
|
+
self._start_transmitted_data(
|
|
691
|
+
jes_xmit_delimiter(raw_params), stmt.line_number
|
|
692
|
+
)
|
|
604
693
|
if self.dialect == "jes3" and stmt.name.upper() not in _JES3_SLASH_STAR:
|
|
605
694
|
# JES3 does not process JES2 control statements: keep the text,
|
|
606
695
|
# but do not interpret it, and say so.
|
|
@@ -640,7 +729,7 @@ class JobBuilder:
|
|
|
640
729
|
pass
|
|
641
730
|
|
|
642
731
|
def _on_control(self, stmt: Statement, line_num: int) -> None:
|
|
643
|
-
"""Record a COMMAND / SCHEDULE / CNTL statement verbatim."""
|
|
732
|
+
"""Record a COMMAND / SCHEDULE / CNTL / NOTIFY statement verbatim."""
|
|
644
733
|
# No richer model representation, but they are real statements —
|
|
645
734
|
# record them verbatim rather than discarding them.
|
|
646
735
|
if stmt.type == "CNTL":
|
|
@@ -659,6 +748,22 @@ class JobBuilder:
|
|
|
659
748
|
if self.current_cntl is not None:
|
|
660
749
|
self.current_cntl.parameters["_records"].append(line)
|
|
661
750
|
|
|
751
|
+
def _on_custom(self, stmt: Statement, line_num: int) -> None:
|
|
752
|
+
"""Keep a statement of a type the builder has no handler for.
|
|
753
|
+
|
|
754
|
+
Such statements come from a :class:`~jclparser.plugins.StatementPlugin`
|
|
755
|
+
returning a custom ``Statement.type``; they used to be dropped
|
|
756
|
+
silently (#130).
|
|
757
|
+
"""
|
|
758
|
+
logger.debug(
|
|
759
|
+
"no handler for statement type %r at line %d; kept in "
|
|
760
|
+
"Job.custom_statements",
|
|
761
|
+
stmt.type,
|
|
762
|
+
line_num,
|
|
763
|
+
)
|
|
764
|
+
sink = self.job.custom_statements if self.job else self.pending_custom
|
|
765
|
+
sink.append(stmt)
|
|
766
|
+
|
|
662
767
|
def _on_unknown(self, stmt: Statement, line_num: int) -> None:
|
|
663
768
|
# Unknown statement type - log but continue processing
|
|
664
769
|
statement_type_name = stmt.parameters.get("_statement_type", "")
|
|
@@ -682,8 +787,6 @@ class JobBuilder:
|
|
|
682
787
|
line_number=stmt.line_number,
|
|
683
788
|
)
|
|
684
789
|
self.current_step.add_dd(self.current_dd)
|
|
685
|
-
self.in_inline_data = True
|
|
686
|
-
self.inline_data_lines = []
|
|
687
790
|
else:
|
|
688
791
|
# Job-level XMIT has no step to attach a DD to, but the
|
|
689
792
|
# transmitted data block that follows must still be
|
|
@@ -693,12 +796,29 @@ class JobBuilder:
|
|
|
693
796
|
# it; clear current_dd so the inline-data-end handler
|
|
694
797
|
# doesn't misattach it to an unrelated earlier DD.
|
|
695
798
|
self.current_dd = None
|
|
696
|
-
self.in_inline_data = True
|
|
697
|
-
self.inline_data_lines = []
|
|
698
799
|
self._warnings_sink().append(
|
|
699
800
|
f"Line {stmt.line_number}: job-level XMIT has no step to attach "
|
|
700
801
|
f"its data to; the transmitted records were discarded"
|
|
701
802
|
)
|
|
803
|
+
self._start_transmitted_data(xmit_delimiter(stmt.parameters), stmt.line_number)
|
|
804
|
+
|
|
805
|
+
def _start_transmitted_data(self, delimiter: Any, line_num: int) -> None:
|
|
806
|
+
"""Open the data block of an XMIT / ``/*XMIT`` statement.
|
|
807
|
+
|
|
808
|
+
The transmitted records are a job stream for another node, so a
|
|
809
|
+
``//`` record never ends them: only ``/*`` or the ``DLM=`` delimiter
|
|
810
|
+
does (the pre-scan in ``_merge_source_lines`` uses the same rule).
|
|
811
|
+
"""
|
|
812
|
+
if not isinstance(delimiter, str):
|
|
813
|
+
raise JCLSyntaxError(
|
|
814
|
+
f"DLM= must be a one- or two-character delimiter, got "
|
|
815
|
+
f"{jcl_value_text(delimiter)}",
|
|
816
|
+
line_num,
|
|
817
|
+
)
|
|
818
|
+
self.in_inline_data = True
|
|
819
|
+
self.inline_data_lines = []
|
|
820
|
+
self.current_delimiter = delimiter
|
|
821
|
+
self.current_terminates_on_slash = False
|
|
702
822
|
|
|
703
823
|
# ------------------------------------------------------------------ #
|
|
704
824
|
# Finalisation
|
|
@@ -767,7 +887,7 @@ class JobBuilder:
|
|
|
767
887
|
return self.job
|
|
768
888
|
if not self.pending_procedures:
|
|
769
889
|
raise JCLSyntaxError("No JOB statement found", 0)
|
|
770
|
-
job = Job(name="", line_number=0)
|
|
890
|
+
job = Job(name="", line_number=0, is_procedure_member=True)
|
|
771
891
|
for proc in self.pending_procedures:
|
|
772
892
|
job.add_procedure(proc)
|
|
773
893
|
# A JOB-less member (a cataloged procedure) has no job
|
|
@@ -805,6 +925,7 @@ class JobBuilder:
|
|
|
805
925
|
job.conditionals[:0] = self.pending_conditionals
|
|
806
926
|
job.steps[:0] = self.pending_steps
|
|
807
927
|
job.control_statements[:0] = self.pending_control
|
|
928
|
+
job.custom_statements[:0] = self.pending_custom
|
|
808
929
|
|
|
809
930
|
def _flush_pending_sets(self, job: Job) -> None:
|
|
810
931
|
# Statements seen before the JOB statement (or in a JOB-less member)
|
|
@@ -820,11 +941,17 @@ class JobBuilder:
|
|
|
820
941
|
for k, v in (step.set_symbols or {}).items()
|
|
821
942
|
):
|
|
822
943
|
step.set_symbols = None
|
|
944
|
+
for procedure, step in self.procedure_steps:
|
|
945
|
+
if all(
|
|
946
|
+
procedure.set_statements.get(k) == v
|
|
947
|
+
for k, v in (step.set_symbols or {}).items()
|
|
948
|
+
):
|
|
949
|
+
step.set_symbols = None
|
|
823
950
|
|
|
824
951
|
def _warn_orphaned_dds(self, job: Job) -> None:
|
|
825
952
|
# orphaned_dds is only ever non-empty here when the whole job had no
|
|
826
|
-
# EXEC statement at all (the first
|
|
827
|
-
# list) -- those DD statements have nowhere to attach and are
|
|
953
|
+
# job-level EXEC statement at all (the first one always flushes and
|
|
954
|
+
# clears the list) -- those DD statements have nowhere to attach and are
|
|
828
955
|
# discarded, so record what was lost instead of vanishing them silently.
|
|
829
956
|
if self.orphaned_dds:
|
|
830
957
|
dd_names = ", ".join(dd.name or "(unnamed)" for dd in self.orphaned_dds)
|