jcl-parser 2.0.1__tar.gz → 2.0.3__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.1 → jcl_parser-2.0.3}/PKG-INFO +2 -2
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jcl_parser.egg-info/PKG-INFO +2 -2
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/__init__.py +1 -1
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/_job_builder.py +27 -2
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/_model_factories.py +56 -6
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/_post_parse.py +67 -20
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/_source_scanner.py +6 -3
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/explosion.py +138 -42
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/model.py +5 -4
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/parser.py +12 -6
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/sql_detector.py +54 -3
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/utils.py +71 -16
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/pyproject.toml +2 -2
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/LICENSE +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/README.md +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jcl_parser.egg-info/SOURCES.txt +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jcl_parser.egg-info/dependency_links.txt +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jcl_parser.egg-info/entry_points.txt +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jcl_parser.egg-info/requires.txt +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jcl_parser.egg-info/top_level.txt +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/__main__.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/_statement_parser.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/builtin_procs.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/cli.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/condition_parser.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/continuation_handler.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/dataset_lifecycle.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/errors.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/include_loader.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/jcl_file_type.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/jes_statements.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/lint/__init__.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/lint/base.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/lint/config.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/lint/rules.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/lint/runner.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/metrics.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/parameter_ast.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/parameter_validators.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/plugins/__init__.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/plugins/base.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/proc_loader.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/profiling.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/__init__.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/_control.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/_invoked.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/adrdssu.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/base.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/binder.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/bpxbatch.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/compilers.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/db2_batch.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/dfsrrc00.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/dsnutilb.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/ftp.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/icetool.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/idcams.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/iebcompr.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/iebdg.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/iebgener.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/iebptpch.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/iebupdte.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/iefbr14.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/iehinitt.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/iehlist.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/iehprogm.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/ikjeft01.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/irxjcl.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/registry.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/program_profiles/sort.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/py.typed +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/rc_analysis.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/system_symbols.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/jclparser/validators.py +0 -0
- {jcl_parser-2.0.1 → jcl_parser-2.0.3}/setup.cfg +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jcl-parser
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.3
|
|
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
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jcl-parser
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.3
|
|
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
|
|
@@ -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.0.3" # x-release-please-version
|
|
24
24
|
__author__ = "JCL Parser Team"
|
|
25
25
|
__description__ = "Parse IBM JCL scripts into Python objects"
|
|
26
26
|
|
|
@@ -131,10 +131,13 @@ class JobBuilder:
|
|
|
131
131
|
"XMIT": "_on_xmit",
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
def __init__(self, dialect: str = "standard") -> None:
|
|
134
|
+
def __init__(self, dialect: str = "standard", strict: bool = False) -> None:
|
|
135
135
|
#: ``ParserConfig.dialect``: decides which JES control statements are
|
|
136
136
|
#: interpreted (see ``jclparser.plugins.SUPPORTED_DIALECTS``).
|
|
137
137
|
self.dialect = dialect
|
|
138
|
+
#: Strict parsing: a SET value over ``MAX_SYMBOL_VALUE_LENGTH`` raises
|
|
139
|
+
#: instead of being left unresolved with a warning.
|
|
140
|
+
self.strict = strict
|
|
138
141
|
self.job: Job | None = None
|
|
139
142
|
#: tuples: (ConditionalBlock, active_branch)
|
|
140
143
|
self.conditional_stack: list[tuple[ConditionalBlock, str]] = []
|
|
@@ -171,6 +174,10 @@ class JobBuilder:
|
|
|
171
174
|
#: only if some SET symbol is redefined after it was first set.
|
|
172
175
|
self.job_level_steps: list[Step] = []
|
|
173
176
|
self.set_redefined = False
|
|
177
|
+
#: Steps of in-stream / cataloged procedures with the procedure they
|
|
178
|
+
#: belong to; finish() prunes their ``set_symbols`` snapshot the same
|
|
179
|
+
#: way (against the procedure's own final SET table).
|
|
180
|
+
self.procedure_steps: list[tuple[Procedure, Step]] = []
|
|
174
181
|
self.pending_exported: set[str] = set()
|
|
175
182
|
self.pending_jcllib: list[str] = []
|
|
176
183
|
self.pending_includes: list[str] = []
|
|
@@ -312,6 +319,11 @@ class JobBuilder:
|
|
|
312
319
|
**self.job.set_statements,
|
|
313
320
|
}
|
|
314
321
|
self.job_level_steps.append(self.current_step)
|
|
322
|
+
elif self.current_procedure is not None:
|
|
323
|
+
# A SET inside the procedure is in effect from where it is coded,
|
|
324
|
+
# so this step sees the procedure's SET values as they stand now.
|
|
325
|
+
self.current_step.set_symbols = dict(self.current_procedure.set_statements)
|
|
326
|
+
self.procedure_steps.append((self.current_procedure, self.current_step))
|
|
315
327
|
self.in_joblib_concat = False # JOBLIB concatenation ends at first EXEC
|
|
316
328
|
self.concat_override_key = None
|
|
317
329
|
|
|
@@ -437,7 +449,14 @@ class JobBuilder:
|
|
|
437
449
|
symbols = self.job.set_statements if self.job else self.pending_sets
|
|
438
450
|
|
|
439
451
|
def _record(symbol: str, value: str) -> None:
|
|
440
|
-
resolved = _resolve_set_value(
|
|
452
|
+
resolved = _resolve_set_value(
|
|
453
|
+
value,
|
|
454
|
+
symbols,
|
|
455
|
+
symbol=symbol,
|
|
456
|
+
line_number=stmt.line_number,
|
|
457
|
+
strict=self.strict,
|
|
458
|
+
warnings=self._warnings_sink(),
|
|
459
|
+
)
|
|
441
460
|
if procedure is not None:
|
|
442
461
|
procedure.set_statements[symbol] = resolved
|
|
443
462
|
elif self.job:
|
|
@@ -810,6 +829,12 @@ class JobBuilder:
|
|
|
810
829
|
for k, v in (step.set_symbols or {}).items()
|
|
811
830
|
):
|
|
812
831
|
step.set_symbols = None
|
|
832
|
+
for procedure, step in self.procedure_steps:
|
|
833
|
+
if all(
|
|
834
|
+
procedure.set_statements.get(k) == v
|
|
835
|
+
for k, v in (step.set_symbols or {}).items()
|
|
836
|
+
):
|
|
837
|
+
step.set_symbols = None
|
|
813
838
|
|
|
814
839
|
def _warn_orphaned_dds(self, job: Job) -> None:
|
|
815
840
|
# orphaned_dds is only ever non-empty here when the whole job had no
|
|
@@ -21,13 +21,22 @@ import re
|
|
|
21
21
|
from collections.abc import Callable
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
+
from .errors import JCLSyntaxError
|
|
24
25
|
from .model import DDStatement, Job, Statement, Step
|
|
25
|
-
from .utils import POSITIONAL_PARAM_PREFIX, is_positional_key
|
|
26
|
+
from .utils import MAX_SYMBOL_VALUE_LENGTH, POSITIONAL_PARAM_PREFIX, is_positional_key
|
|
26
27
|
|
|
27
28
|
_SET_SYMBOL_PATTERN = re.compile(r"&&|&([A-Z0-9@#$]+)\.?")
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
def _resolve_set_value(
|
|
31
|
+
def _resolve_set_value(
|
|
32
|
+
value: str,
|
|
33
|
+
known_symbols: dict[str, str],
|
|
34
|
+
*,
|
|
35
|
+
symbol: str = "",
|
|
36
|
+
line_number: int = 0,
|
|
37
|
+
strict: bool = False,
|
|
38
|
+
warnings: list[str] | None = None,
|
|
39
|
+
) -> str:
|
|
31
40
|
"""Resolve &SYMBOL references in a SET value using already-accumulated SET symbols.
|
|
32
41
|
|
|
33
42
|
Mirrors the mainframe rule: SET values are evaluated left-to-right so a
|
|
@@ -36,15 +45,56 @@ def _resolve_set_value(value: str, known_symbols: dict[str, str]) -> str:
|
|
|
36
45
|
itself holds a "&&NAME" temporary-dataset marker (e.g. ``SET
|
|
37
46
|
MYTEMP=&&WORK``) before it ever reaches a DD statement. That collapse is
|
|
38
47
|
explode_job()'s job (Tier 2), not this Tier-1 SET-value resolution.
|
|
48
|
+
|
|
49
|
+
A resolved value longer than :data:`~jclparser.utils.MAX_SYMBOL_VALUE_LENGTH`
|
|
50
|
+
is never built (#70): its length is computed first, and the value is
|
|
51
|
+
returned unresolved with a message on *warnings* -- or, when *strict*,
|
|
52
|
+
:class:`~jclparser.errors.JCLSyntaxError` is raised. *symbol* and
|
|
53
|
+
*line_number* only label that message.
|
|
39
54
|
"""
|
|
55
|
+
matches = list(_SET_SYMBOL_PATTERN.finditer(value))
|
|
56
|
+
if not matches:
|
|
57
|
+
return value
|
|
40
58
|
|
|
41
|
-
def
|
|
59
|
+
def _replacement(m: re.Match[str]) -> str:
|
|
42
60
|
if m.group(0) == "&&":
|
|
43
61
|
return "&&"
|
|
44
|
-
|
|
45
|
-
return known_symbols.get(sym, m.group(0))
|
|
62
|
+
return known_symbols.get(m.group(1).upper(), m.group(0))
|
|
46
63
|
|
|
47
|
-
|
|
64
|
+
replacements = [_replacement(m) for m in matches]
|
|
65
|
+
length = len(value) + sum(
|
|
66
|
+
len(r) - len(m.group(0)) for m, r in zip(matches, replacements)
|
|
67
|
+
)
|
|
68
|
+
if length > MAX_SYMBOL_VALUE_LENGTH:
|
|
69
|
+
_report_set_overflow(symbol, length, line_number, strict, warnings)
|
|
70
|
+
return value
|
|
71
|
+
parts: list[str] = []
|
|
72
|
+
pos = 0
|
|
73
|
+
for m, r in zip(matches, replacements):
|
|
74
|
+
parts.append(value[pos : m.start()])
|
|
75
|
+
parts.append(r)
|
|
76
|
+
pos = m.end()
|
|
77
|
+
parts.append(value[pos:])
|
|
78
|
+
return "".join(parts)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _report_set_overflow(
|
|
82
|
+
symbol: str,
|
|
83
|
+
length: int,
|
|
84
|
+
line_number: int,
|
|
85
|
+
strict: bool,
|
|
86
|
+
warnings: list[str] | None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Raise (strict) or warn that SET *symbol* would resolve to *length* chars."""
|
|
89
|
+
message = (
|
|
90
|
+
f"SET symbol &{symbol or '?'} would resolve to {length:,} characters, "
|
|
91
|
+
f"more than the {MAX_SYMBOL_VALUE_LENGTH:,}-character limit"
|
|
92
|
+
)
|
|
93
|
+
if strict:
|
|
94
|
+
raise JCLSyntaxError(message, line_number)
|
|
95
|
+
if warnings is not None:
|
|
96
|
+
prefix = f"Line {line_number}: " if line_number else ""
|
|
97
|
+
warnings.append(f"{prefix}{message}; value left unresolved")
|
|
48
98
|
|
|
49
99
|
|
|
50
100
|
def _has_unresolved_symbol_ref(
|
|
@@ -246,7 +246,7 @@ def enforce_strict_dd_structure(content: str) -> None:
|
|
|
246
246
|
)
|
|
247
247
|
|
|
248
248
|
|
|
249
|
-
def resolve_set_symbols(job: Job) -> None:
|
|
249
|
+
def resolve_set_symbols(job: Job, strict: bool = False) -> None:
|
|
250
250
|
"""Substitute fully-resolved SET symbols into DD and EXEC-step parameters.
|
|
251
251
|
|
|
252
252
|
SET statements may appear before or after the DD statements that reference
|
|
@@ -274,11 +274,17 @@ def resolve_set_symbols(job: Job) -> None:
|
|
|
274
274
|
pass. It also covers EXEC step parameters (e.g. ``PARM=&SYM``), not just
|
|
275
275
|
DD dataset names/dispositions, so a job-level SET symbol resolves the same
|
|
276
276
|
way regardless of which kind of statement references it.
|
|
277
|
+
|
|
278
|
+
A SET value whose expansion would exceed ``utils.MAX_SYMBOL_VALUE_LENGTH``
|
|
279
|
+
characters is left unresolved with one warning on ``job.warnings`` (#70);
|
|
280
|
+
a statement referencing it keeps the reference as written (again with a
|
|
281
|
+
warning). With *strict*, :class:`~jclparser.errors.JCLSyntaxError` is
|
|
282
|
+
raised instead.
|
|
277
283
|
"""
|
|
278
284
|
from .explosion import substitute_dd_statement
|
|
279
285
|
from .system_symbols import is_system_symbol_name
|
|
280
286
|
|
|
281
|
-
_set_scope = _scope_for_table(job.set_statements)
|
|
287
|
+
_set_scope = _scope_for_table(job.set_statements, job.warnings, strict)
|
|
282
288
|
# A step read before a SET redefined a symbol sees the value in effect at
|
|
283
289
|
# that point (Step.set_symbols); symbols it has no snapshot for (forward
|
|
284
290
|
# references) still fall back to the job's final table.
|
|
@@ -290,7 +296,7 @@ def resolve_set_symbols(job: Job) -> None:
|
|
|
290
296
|
for _owner, _dd in _dds_with_owners(_flat_steps):
|
|
291
297
|
substitute_dd_statement(
|
|
292
298
|
_dd,
|
|
293
|
-
_scope_for_step(_owner, job, _set_scope, _step_scopes),
|
|
299
|
+
_scope_for_step(_owner, job, _set_scope, _step_scopes, strict),
|
|
294
300
|
strict=False,
|
|
295
301
|
warnings=job.warnings,
|
|
296
302
|
collapse_double_amp=False,
|
|
@@ -298,14 +304,18 @@ def resolve_set_symbols(job: Job) -> None:
|
|
|
298
304
|
)
|
|
299
305
|
|
|
300
306
|
for _step in _flat_steps:
|
|
301
|
-
_step_scope = _scope_for_step(_step, job, _set_scope, _step_scopes)
|
|
307
|
+
_step_scope = _scope_for_step(_step, job, _set_scope, _step_scopes, strict)
|
|
302
308
|
_substitute_step_parameters(_step, _step_scope, job.warnings)
|
|
303
309
|
_substitute_step_names(_step, _step_scope)
|
|
304
310
|
|
|
305
311
|
|
|
306
|
-
def _preresolve_set_table(table: dict[str, str]) -> dict[str, str]:
|
|
307
|
-
"""Return a copy of SET *table* with symbol references between values expanded.
|
|
308
|
-
|
|
312
|
+
def _preresolve_set_table(table: dict[str, str]) -> tuple[dict[str, str], list[str]]:
|
|
313
|
+
"""Return a copy of SET *table* with symbol references between values expanded.
|
|
314
|
+
|
|
315
|
+
Also returns the names of the symbols whose expansion would exceed
|
|
316
|
+
``utils.MAX_SYMBOL_VALUE_LENGTH``; those keep their unexpanded value.
|
|
317
|
+
"""
|
|
318
|
+
from .explosion import SymbolScope, _substitute_symbols_bounded, _SymbolOverflow
|
|
309
319
|
|
|
310
320
|
# Pre-resolve the symbol table so every value is fully expanded before
|
|
311
321
|
# we apply it to DD dataset names. On each pass we substitute symbols
|
|
@@ -313,36 +323,67 @@ def _preresolve_set_table(table: dict[str, str]) -> dict[str, str]:
|
|
|
313
323
|
# (excluding the symbol itself prevents direct self-reference loops).
|
|
314
324
|
# We repeat until no value changes or we reach the pass limit.
|
|
315
325
|
_raw: dict[str, str] = dict(table)
|
|
326
|
+
_overflowed: list[str] = []
|
|
316
327
|
for _pass in range(10):
|
|
317
328
|
_changed = False
|
|
318
329
|
for _name, _val in list(_raw.items()):
|
|
319
|
-
if "&" not in _val:
|
|
330
|
+
if "&" not in _val or _name in _overflowed:
|
|
320
331
|
continue
|
|
321
332
|
# Build a temporary scope that excludes this symbol's own name
|
|
322
333
|
# so "&A." inside SET A's value does not expand to itself.
|
|
323
334
|
_scope_for_val = SymbolScope(
|
|
324
335
|
set_symbols={k: v for k, v in _raw.items() if k != _name}
|
|
325
336
|
)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
337
|
+
try:
|
|
338
|
+
_new_val = _substitute_symbols_bounded(
|
|
339
|
+
_val,
|
|
340
|
+
_scope_for_val,
|
|
341
|
+
allow_undefined=True,
|
|
342
|
+
collapse_double_amp=False,
|
|
343
|
+
)
|
|
344
|
+
except _SymbolOverflow:
|
|
345
|
+
# Values only grow from pass to pass: never retry it.
|
|
346
|
+
_overflowed.append(_name)
|
|
347
|
+
continue
|
|
332
348
|
if _new_val != _val:
|
|
333
349
|
_raw[_name] = _new_val
|
|
334
350
|
_changed = True
|
|
335
351
|
if not _changed:
|
|
336
352
|
break # table has stabilised – no need for more passes
|
|
337
|
-
return _raw
|
|
353
|
+
return _raw, _overflowed
|
|
338
354
|
|
|
339
355
|
|
|
340
|
-
def
|
|
341
|
-
|
|
356
|
+
def _report_table_overflow(
|
|
357
|
+
overflowed: list[str], warnings: list[str], strict: bool
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Raise (strict) or warn once that the *overflowed* SET symbols stay unresolved."""
|
|
360
|
+
from .utils import MAX_SYMBOL_VALUE_LENGTH
|
|
361
|
+
|
|
362
|
+
names = ", ".join(f"&{n}" for n in overflowed)
|
|
363
|
+
message = (
|
|
364
|
+
f"SET symbol(s) {names} expand to more than "
|
|
365
|
+
f"{MAX_SYMBOL_VALUE_LENGTH:,} characters"
|
|
366
|
+
)
|
|
367
|
+
if strict:
|
|
368
|
+
raise JCLSyntaxError(message)
|
|
369
|
+
message += "; left unresolved"
|
|
370
|
+
if message not in warnings:
|
|
371
|
+
warnings.append(message)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _scope_for_table(
|
|
375
|
+
table: dict[str, str], warnings: list[str], strict: bool = False
|
|
376
|
+
) -> SymbolScope:
|
|
377
|
+
"""Build the substitution scope for SET symbol *table* (see resolve_set_symbols).
|
|
378
|
+
|
|
379
|
+
Overflow of a SET value is reported on *warnings* (or raised, *strict*).
|
|
380
|
+
"""
|
|
342
381
|
from .explosion import SymbolScope
|
|
343
382
|
from .system_symbols import is_system_symbol_name
|
|
344
383
|
|
|
345
|
-
_raw = _preresolve_set_table(table)
|
|
384
|
+
_raw, _overflowed = _preresolve_set_table(table)
|
|
385
|
+
if _overflowed:
|
|
386
|
+
_report_table_overflow(_overflowed, warnings, strict)
|
|
346
387
|
|
|
347
388
|
# Symbols whose values still contain a genuinely unresolved
|
|
348
389
|
# &-reference after all passes are excluded from the final scope.
|
|
@@ -355,10 +396,15 @@ def _scope_for_table(table: dict[str, str]) -> SymbolScope:
|
|
|
355
396
|
# ``DSN=&SYSUID..WORK`` before that pass runs (keep_undefined below). A value that legitimately contains "&&" (e.g.
|
|
356
397
|
# SET MYTEMP=&&WORK) is NOT excluded -- it has nothing left to
|
|
357
398
|
# resolve, it just happens to contain an escaped literal ampersand.
|
|
399
|
+
#
|
|
400
|
+
# An overflowed symbol keeps its unexpanded value in the scope, so a
|
|
401
|
+
# statement referencing it overflows the substitution budget in turn and
|
|
402
|
+
# keeps the reference as written instead of having it blanked (#70).
|
|
358
403
|
_resolved_symbols = {
|
|
359
404
|
k: v
|
|
360
405
|
for k, v in _raw.items()
|
|
361
|
-
if
|
|
406
|
+
if k in _overflowed
|
|
407
|
+
or not _has_unresolved_symbol_ref(v, ignore=is_system_symbol_name)
|
|
362
408
|
}
|
|
363
409
|
return SymbolScope(set_symbols=_resolved_symbols)
|
|
364
410
|
|
|
@@ -368,6 +414,7 @@ def _scope_for_step(
|
|
|
368
414
|
job: Job,
|
|
369
415
|
set_scope: SymbolScope,
|
|
370
416
|
step_scopes: dict[int, SymbolScope],
|
|
417
|
+
strict: bool = False,
|
|
371
418
|
) -> SymbolScope:
|
|
372
419
|
"""Return the SET scope in effect for *step*, memoised in *step_scopes*.
|
|
373
420
|
|
|
@@ -378,7 +425,7 @@ def _scope_for_step(
|
|
|
378
425
|
return set_scope
|
|
379
426
|
if id(step) not in step_scopes:
|
|
380
427
|
step_scopes[id(step)] = _scope_for_table(
|
|
381
|
-
{**job.set_statements, **step.set_symbols}
|
|
428
|
+
{**job.set_statements, **step.set_symbols}, job.warnings, strict
|
|
382
429
|
)
|
|
383
430
|
return step_scopes[id(step)]
|
|
384
431
|
|
|
@@ -31,7 +31,7 @@ from .errors import JCLSyntaxError
|
|
|
31
31
|
from .utils import (
|
|
32
32
|
instream_delimiter,
|
|
33
33
|
is_continued_card_image,
|
|
34
|
-
|
|
34
|
+
normalize_jcl_lines,
|
|
35
35
|
split_source_lines,
|
|
36
36
|
)
|
|
37
37
|
|
|
@@ -60,7 +60,7 @@ def _merge_source_lines(content: str) -> ScannedSource:
|
|
|
60
60
|
or garbled records).
|
|
61
61
|
"""
|
|
62
62
|
raw_lines = split_source_lines(content)
|
|
63
|
-
lines =
|
|
63
|
+
lines = normalize_jcl_lines(raw_lines)
|
|
64
64
|
|
|
65
65
|
classifier = _RecordClassifier(lines)
|
|
66
66
|
jcl_indices = classifier.run()
|
|
@@ -116,7 +116,10 @@ def merge_continuations(
|
|
|
116
116
|
card_images={
|
|
117
117
|
n
|
|
118
118
|
for n, orig in enumerate(indices)
|
|
119
|
-
if is_continued_card_image(
|
|
119
|
+
if is_continued_card_image(
|
|
120
|
+
raw_lines[orig],
|
|
121
|
+
raw_lines[orig + 1] if orig + 1 < len(raw_lines) else None,
|
|
122
|
+
)
|
|
120
123
|
},
|
|
121
124
|
)
|
|
122
125
|
|
|
@@ -36,7 +36,7 @@ from .proc_loader import (
|
|
|
36
36
|
create_default_loader,
|
|
37
37
|
)
|
|
38
38
|
from .system_symbols import SystemSymbolProvider, get_default_provider
|
|
39
|
-
from .utils import jcl_value_text
|
|
39
|
+
from .utils import MAX_SYMBOL_VALUE_LENGTH, jcl_value_text
|
|
40
40
|
|
|
41
41
|
logger = logging.getLogger(__name__)
|
|
42
42
|
|
|
@@ -53,10 +53,23 @@ _SYMBOL_SUB_PATTERN = re.compile(r"&&|&([A-Z0-9@#$]+)\.?")
|
|
|
53
53
|
# how deep those nested references are followed.
|
|
54
54
|
_MAX_NESTED_SUBSTITUTION = 8
|
|
55
55
|
|
|
56
|
-
# Total characters
|
|
57
|
-
# Fan-out over distinct symbols
|
|
58
|
-
#
|
|
59
|
-
|
|
56
|
+
# Total characters the symbol values spliced in by one substitution may add
|
|
57
|
+
# up to (``utils.MAX_SYMBOL_VALUE_LENGTH``). Fan-out over distinct symbols
|
|
58
|
+
# (``A='&B&B&B'``, ``B='&C&C&C'`` ...) grows exponentially with depth even
|
|
59
|
+
# without a cycle, so depth alone is no bound (#70).
|
|
60
|
+
_MAX_NESTED_EXPANSION_CHARS = MAX_SYMBOL_VALUE_LENGTH
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _SymbolOverflow(Exception):
|
|
64
|
+
"""Internal: a substitution would splice in more than the character budget."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _overflow_message(source_text: str) -> str:
|
|
68
|
+
return (
|
|
69
|
+
f"Symbols in '{source_text[:80]}' expand to more than "
|
|
70
|
+
f"{_MAX_NESTED_EXPANSION_CHARS:,} characters"
|
|
71
|
+
)
|
|
72
|
+
|
|
60
73
|
|
|
61
74
|
# A value ending in a bare ``&NAME`` reference (not the ``&&NAME`` literal).
|
|
62
75
|
_TRAILING_SYMBOL_REF = re.compile(r"(?<!&)&[A-Z0-9@#$]+$")
|
|
@@ -319,10 +332,13 @@ def _make_replacer(
|
|
|
319
332
|
*_resolving* holds the symbols whose values are being expanded above this
|
|
320
333
|
level; a reference back to one of them is a cycle and is left as written.
|
|
321
334
|
*_budget* is a one-element list shared by every nested level, counting
|
|
322
|
-
down the characters
|
|
335
|
+
down the characters the substitution may still splice in; running out
|
|
336
|
+
raises :class:`_SymbolOverflow`, so no level builds a string much longer
|
|
337
|
+
than the budget.
|
|
323
338
|
"""
|
|
324
339
|
if _budget is None:
|
|
325
340
|
_budget = [_MAX_NESTED_EXPANSION_CHARS]
|
|
341
|
+
budget = _budget
|
|
326
342
|
|
|
327
343
|
def _replacer(m: re.Match[str]) -> str:
|
|
328
344
|
if m.group(0) == "&&":
|
|
@@ -361,17 +377,13 @@ def _make_replacer(
|
|
|
361
377
|
collapse_double_amp=False,
|
|
362
378
|
_depth=_depth + 1,
|
|
363
379
|
_resolving=_resolving | {symbol_name},
|
|
364
|
-
_budget=
|
|
380
|
+
_budget=budget,
|
|
365
381
|
),
|
|
366
382
|
value,
|
|
367
383
|
)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
f"Symbol &{symbol_name} expands to more than "
|
|
372
|
-
f"{_MAX_NESTED_EXPANSION_CHARS:,} characters in "
|
|
373
|
-
f"'{source_text[:80]}'"
|
|
374
|
-
)
|
|
384
|
+
budget[0] -= len(value)
|
|
385
|
+
if budget[0] < 0:
|
|
386
|
+
raise _SymbolOverflow
|
|
375
387
|
if _TRAILING_SYMBOL_REF.search(value):
|
|
376
388
|
# Splicing a bare ``&SYSUID`` in front of ``.DATA`` would make that
|
|
377
389
|
# period the reference's delimiter; give it its own.
|
|
@@ -381,6 +393,30 @@ def _make_replacer(
|
|
|
381
393
|
return _replacer
|
|
382
394
|
|
|
383
395
|
|
|
396
|
+
def _substitute_symbols_bounded(
|
|
397
|
+
text: str,
|
|
398
|
+
scope: "SymbolScope",
|
|
399
|
+
strict: bool = False,
|
|
400
|
+
allow_undefined: bool = False,
|
|
401
|
+
warnings: list[str] | None = None,
|
|
402
|
+
collapse_double_amp: bool = True,
|
|
403
|
+
keep_undefined: Callable[[str], bool] | None = None,
|
|
404
|
+
) -> str:
|
|
405
|
+
""":func:`substitute_symbols` that raises :class:`_SymbolOverflow` on overflow."""
|
|
406
|
+
return _SYMBOL_SUB_PATTERN.sub(
|
|
407
|
+
_make_replacer(
|
|
408
|
+
scope,
|
|
409
|
+
strict,
|
|
410
|
+
allow_undefined,
|
|
411
|
+
source_text=text,
|
|
412
|
+
warnings=warnings,
|
|
413
|
+
collapse_double_amp=collapse_double_amp,
|
|
414
|
+
keep_undefined=keep_undefined,
|
|
415
|
+
),
|
|
416
|
+
text,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
384
420
|
def substitute_symbols(
|
|
385
421
|
text: str,
|
|
386
422
|
scope: "SymbolScope",
|
|
@@ -415,25 +451,35 @@ def substitute_symbols(
|
|
|
415
451
|
symbols) can still resolve it.
|
|
416
452
|
|
|
417
453
|
Returns:
|
|
418
|
-
Text with symbols substituted
|
|
454
|
+
Text with symbols substituted. When the substituted values would add
|
|
455
|
+
up to more than ``utils.MAX_SYMBOL_VALUE_LENGTH`` characters (chained
|
|
456
|
+
symbols that double at each step), *text* is returned unchanged and a
|
|
457
|
+
message goes to *warnings* (#70).
|
|
419
458
|
|
|
420
459
|
Raises:
|
|
421
460
|
UndefinedSymbolError: If strict=True and symbol undefined
|
|
461
|
+
JCLStructureError: If strict=True and the substitution would exceed
|
|
462
|
+
``utils.MAX_SYMBOL_VALUE_LENGTH`` characters
|
|
422
463
|
"""
|
|
423
464
|
if not text or not isinstance(text, str):
|
|
424
465
|
return text
|
|
425
|
-
|
|
426
|
-
|
|
466
|
+
try:
|
|
467
|
+
return _substitute_symbols_bounded(
|
|
468
|
+
text,
|
|
427
469
|
scope,
|
|
428
|
-
strict,
|
|
429
|
-
allow_undefined,
|
|
430
|
-
source_text=text,
|
|
470
|
+
strict=strict,
|
|
471
|
+
allow_undefined=allow_undefined,
|
|
431
472
|
warnings=warnings,
|
|
432
473
|
collapse_double_amp=collapse_double_amp,
|
|
433
474
|
keep_undefined=keep_undefined,
|
|
434
|
-
)
|
|
435
|
-
|
|
436
|
-
|
|
475
|
+
)
|
|
476
|
+
except _SymbolOverflow:
|
|
477
|
+
message = _overflow_message(text)
|
|
478
|
+
if strict:
|
|
479
|
+
raise JCLStructureError(message) from None
|
|
480
|
+
if warnings is not None:
|
|
481
|
+
warnings.append(f"{message}; left unresolved")
|
|
482
|
+
return text
|
|
437
483
|
|
|
438
484
|
|
|
439
485
|
def detect_circular_refs(
|
|
@@ -523,6 +569,8 @@ def expand_proc_invocation(
|
|
|
523
569
|
else:
|
|
524
570
|
# Clone the step
|
|
525
571
|
new_step = _clone_step_for_explosion(proc_step)
|
|
572
|
+
step_scope = _proc_step_scope(expansion_scope, proc_step)
|
|
573
|
+
new_step.set_symbols = None
|
|
526
574
|
|
|
527
575
|
# Generate qualified step name
|
|
528
576
|
if new_step.name:
|
|
@@ -538,18 +586,28 @@ def expand_proc_invocation(
|
|
|
538
586
|
if new_step.program:
|
|
539
587
|
new_step.program = substitute_symbols(
|
|
540
588
|
new_step.program,
|
|
541
|
-
|
|
589
|
+
step_scope,
|
|
542
590
|
strict=strict,
|
|
543
591
|
warnings=warnings,
|
|
544
592
|
)
|
|
545
593
|
|
|
546
|
-
# Substitute symbols in step parameters
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
594
|
+
# Substitute symbols in step parameters. A nested
|
|
595
|
+
# ``EXEC procname`` keeps "&&" in its symbolic values, as a
|
|
596
|
+
# top-level one does (TMP='&&WORK' is a temporary dataset).
|
|
597
|
+
if new_step.is_procedure:
|
|
598
|
+
new_step.parameters = _substitute_proc_call_parameters(
|
|
599
|
+
new_step.parameters,
|
|
600
|
+
step_scope,
|
|
601
|
+
strict=strict,
|
|
602
|
+
warnings=warnings,
|
|
603
|
+
)
|
|
604
|
+
else:
|
|
605
|
+
new_step.parameters = substitute_parameters_dict(
|
|
606
|
+
new_step.parameters,
|
|
607
|
+
step_scope,
|
|
608
|
+
strict=strict,
|
|
609
|
+
warnings=warnings,
|
|
610
|
+
)
|
|
553
611
|
if "COND" in new_step.parameters:
|
|
554
612
|
new_step.parameters["COND"] = _qualify_cond_parameter(
|
|
555
613
|
new_step.parameters["COND"], proc_steps, prefix
|
|
@@ -559,7 +617,7 @@ def expand_proc_invocation(
|
|
|
559
617
|
# for a nested ``EXEC procname``, its PROCSTEP.DDNAME overrides
|
|
560
618
|
for dd in _step_dds_and_overrides(new_step):
|
|
561
619
|
substitute_dd_statement(
|
|
562
|
-
dd,
|
|
620
|
+
dd, step_scope, strict=strict, warnings=warnings
|
|
563
621
|
)
|
|
564
622
|
|
|
565
623
|
result.append(new_step)
|
|
@@ -570,6 +628,24 @@ def expand_proc_invocation(
|
|
|
570
628
|
return expanded_steps
|
|
571
629
|
|
|
572
630
|
|
|
631
|
+
def _proc_step_scope(expansion_scope: SymbolScope, proc_step: Step) -> SymbolScope:
|
|
632
|
+
"""Return *expansion_scope* with the PROC SETs in effect at *proc_step*.
|
|
633
|
+
|
|
634
|
+
A SET inside a procedure is in effect from where it is coded: a step read
|
|
635
|
+
before a later SET redefined a symbol keeps the earlier value
|
|
636
|
+
(``Step.set_symbols``). Symbols it has no snapshot for still resolve
|
|
637
|
+
against the procedure's final SET table.
|
|
638
|
+
"""
|
|
639
|
+
if proc_step.set_symbols is None:
|
|
640
|
+
return expansion_scope
|
|
641
|
+
return SymbolScope(
|
|
642
|
+
set_symbols={**expansion_scope.set_symbols, **proc_step.set_symbols},
|
|
643
|
+
proc_params=expansion_scope.proc_params,
|
|
644
|
+
exec_overrides=expansion_scope.exec_overrides,
|
|
645
|
+
system_provider=expansion_scope.system_provider,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
573
649
|
_WHOLE_SYMBOL_RE = re.compile(r"&([A-Z0-9@#$]+)\.?")
|
|
574
650
|
|
|
575
651
|
|
|
@@ -968,6 +1044,33 @@ _EXEC_OVERRIDE_KEYWORDS = frozenset(
|
|
|
968
1044
|
_PARM_KEYWORDS = frozenset({"PARM", "PARMDD"})
|
|
969
1045
|
|
|
970
1046
|
|
|
1047
|
+
def _substitute_proc_call_parameters(
|
|
1048
|
+
parameters: dict[str, Any],
|
|
1049
|
+
scope: SymbolScope,
|
|
1050
|
+
strict: bool = False,
|
|
1051
|
+
allow_undefined: bool = False,
|
|
1052
|
+
warnings: list[str] | None = None,
|
|
1053
|
+
) -> dict[str, Any]:
|
|
1054
|
+
"""Substitute symbols in the parameters of an ``EXEC procname`` statement.
|
|
1055
|
+
|
|
1056
|
+
EXEC keyword overrides (``PARM.PS1='A&&B'``) collapse "&&" to "&" like any
|
|
1057
|
+
other step parameter; a symbolic value keeps it, because it may carry a
|
|
1058
|
+
temporary dataset name (``TMP='&&WORK'``) into the procedure's DDs.
|
|
1059
|
+
"""
|
|
1060
|
+
return {
|
|
1061
|
+
key: substitute_parameters_dict(
|
|
1062
|
+
{key: value},
|
|
1063
|
+
scope,
|
|
1064
|
+
strict=strict,
|
|
1065
|
+
allow_undefined=allow_undefined,
|
|
1066
|
+
warnings=warnings,
|
|
1067
|
+
collapse_double_amp=key.upper().partition(".")[0]
|
|
1068
|
+
in _EXEC_OVERRIDE_KEYWORDS,
|
|
1069
|
+
)[key]
|
|
1070
|
+
for key, value in parameters.items()
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
|
|
971
1074
|
def _set_exec_keyword(step: Step, keyword: str, value: Any) -> None:
|
|
972
1075
|
"""Override (or, for an empty value, nullify) *keyword* on *step*."""
|
|
973
1076
|
if keyword in _PARM_KEYWORDS:
|
|
@@ -1341,16 +1444,9 @@ def explode_job( # noqa: C901 -- complexity baseline, split when touched
|
|
|
1341
1444
|
# So are its EXEC keyword overrides (PARM.PS1='&SYSUID') and
|
|
1342
1445
|
# symbolic values (HLQ=&SYSUID). A symbolic value keeps "&&":
|
|
1343
1446
|
# it may carry a temporary dataset name (TMP='&&WORK').
|
|
1344
|
-
step.parameters =
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
step_scope,
|
|
1348
|
-
allow_undefined=True,
|
|
1349
|
-
collapse_double_amp=key.upper().partition(".")[0]
|
|
1350
|
-
in _EXEC_OVERRIDE_KEYWORDS,
|
|
1351
|
-
)[key]
|
|
1352
|
-
for key, value in step.parameters.items()
|
|
1353
|
-
}
|
|
1447
|
+
step.parameters = _substitute_proc_call_parameters(
|
|
1448
|
+
step.parameters, step_scope, allow_undefined=True
|
|
1449
|
+
)
|
|
1354
1450
|
# Expand procedure
|
|
1355
1451
|
expanded = _explode_step(
|
|
1356
1452
|
step=step,
|
|
@@ -542,10 +542,11 @@ class Step:
|
|
|
542
542
|
dd_override_concatenations: dict[str, list[DDStatement]] = field(
|
|
543
543
|
default_factory=dict
|
|
544
544
|
)
|
|
545
|
-
#: The job's SET symbols as they stood when this step's EXEC was read
|
|
546
|
-
#:
|
|
547
|
-
#:
|
|
548
|
-
#:
|
|
545
|
+
#: The job's SET symbols as they stood when this step's EXEC was read
|
|
546
|
+
#: (for a step inside a PROC body, the procedure's own SETs), kept only
|
|
547
|
+
#: when a later ``SET`` redefines one of them (``None`` otherwise): a SET
|
|
548
|
+
#: value is in effect from the SET statement onward, so this step's
|
|
549
|
+
#: ``&SYM`` references must not see the redefinition.
|
|
549
550
|
set_symbols: dict[str, str] | None = field(default=None, repr=False, compare=False)
|
|
550
551
|
|
|
551
552
|
@property
|
|
@@ -137,7 +137,7 @@ def _parse_jcl_core(
|
|
|
137
137
|
)
|
|
138
138
|
|
|
139
139
|
scanned = _merge_source_lines(content)
|
|
140
|
-
job = _build_job(scanned, config)
|
|
140
|
+
job = _build_job(scanned, config, strict=strict_mode)
|
|
141
141
|
|
|
142
142
|
# Anything the line-classification pass discarded or repaired (a comment
|
|
143
143
|
# field that looked like operands, an unterminated CNTL block) is reported
|
|
@@ -156,7 +156,7 @@ def _parse_jcl_core(
|
|
|
156
156
|
if strict_mode and not job.steps:
|
|
157
157
|
enforce_strict_dd_structure(content)
|
|
158
158
|
|
|
159
|
-
_resolve_references(job, expanded_includes)
|
|
159
|
+
_resolve_references(job, expanded_includes, strict=strict_mode)
|
|
160
160
|
|
|
161
161
|
if strict_mode and validate_job:
|
|
162
162
|
_raise_on_fatal_findings(job)
|
|
@@ -208,13 +208,17 @@ def _expand_includes(
|
|
|
208
208
|
return content, expanded
|
|
209
209
|
|
|
210
210
|
|
|
211
|
-
def _build_job(
|
|
211
|
+
def _build_job(
|
|
212
|
+
scanned: ScannedSource, config: ParserConfig | None, *, strict: bool = False
|
|
213
|
+
) -> Job:
|
|
212
214
|
"""Run the parse loop over the scanned source lines.
|
|
213
215
|
|
|
214
216
|
The builder holds all loop state and has one handler per statement type
|
|
215
217
|
(see jclparser/_job_builder.py).
|
|
216
218
|
"""
|
|
217
|
-
builder = JobBuilder(
|
|
219
|
+
builder = JobBuilder(
|
|
220
|
+
dialect=config.dialect if config is not None else "standard", strict=strict
|
|
221
|
+
)
|
|
218
222
|
for line, line_num in scanned.merged_lines:
|
|
219
223
|
# Handle inline data continuation
|
|
220
224
|
if builder.consume_inline_data(line):
|
|
@@ -250,7 +254,9 @@ def _build_job(scanned: ScannedSource, config: ParserConfig | None) -> Job:
|
|
|
250
254
|
return builder.finish()
|
|
251
255
|
|
|
252
256
|
|
|
253
|
-
def _resolve_references(
|
|
257
|
+
def _resolve_references(
|
|
258
|
+
job: Job, expanded_includes: list[str], *, strict: bool = False
|
|
259
|
+
) -> None:
|
|
254
260
|
"""Post-parse passes that need the whole job: INCLUDEs, OUTPUT=, SET."""
|
|
255
261
|
# Record any INCLUDE members that were expanded during pre-processing.
|
|
256
262
|
# Members resolved before the main parse loop never reach the INCLUDE
|
|
@@ -271,7 +277,7 @@ def _resolve_references(job: Job, expanded_includes: list[str]) -> None:
|
|
|
271
277
|
"resolving %d SET symbol(s) across DD statements",
|
|
272
278
|
len(job.set_statements),
|
|
273
279
|
)
|
|
274
|
-
resolve_set_symbols(job)
|
|
280
|
+
resolve_set_symbols(job, strict=strict)
|
|
275
281
|
|
|
276
282
|
|
|
277
283
|
def _raise_on_fatal_findings(job: Job) -> None:
|
|
@@ -78,6 +78,15 @@ NON_SQL_CONTROL_PROGRAMS: frozenset[str] = frozenset(
|
|
|
78
78
|
}
|
|
79
79
|
)
|
|
80
80
|
|
|
81
|
+
# DB2 online-utility programs. Their in-stream data is utility control
|
|
82
|
+
# statements (``RUNSTATS ... UPDATE ALL``, ``MODIFY RECOVERY ... DELETE AGE(n)``)
|
|
83
|
+
# whose options reuse SQL verbs; only the SQL inside an ``EXEC SQL ...
|
|
84
|
+
# ENDEXEC`` utility statement is SQL.
|
|
85
|
+
DB2_UTILITY_CONTROL_PROGRAMS: frozenset[str] = frozenset({"DSNUTILB"})
|
|
86
|
+
|
|
87
|
+
_EXEC_SQL_RE = re.compile(r"\bEXEC\s+SQL\b", re.IGNORECASE)
|
|
88
|
+
_ENDEXEC_RE = re.compile(r"\bENDEXEC\b", re.IGNORECASE)
|
|
89
|
+
|
|
81
90
|
# SYSTSIN holds TSO commands (``DELETE``, ``RENAME`` and ``ALTER`` are TSO
|
|
82
91
|
# commands too); only the DB2 DSN subcommands in it are SQL-related.
|
|
83
92
|
_TSO_COMMAND_DD = "SYSTSIN"
|
|
@@ -178,6 +187,39 @@ def detect_sql_in_text(text: str) -> list[tuple[str, str]]:
|
|
|
178
187
|
return results
|
|
179
188
|
|
|
180
189
|
|
|
190
|
+
def detect_sql_in_utility_statements(text: str) -> list[tuple[str, str]]:
|
|
191
|
+
"""Scan DB2 utility control statements for SQL.
|
|
192
|
+
|
|
193
|
+
Only ``EXEC SQL ... ENDEXEC`` utility statements contribute: the
|
|
194
|
+
``EXEC SQL`` marker itself (``EMBEDDED``) and the SQL statement keywords
|
|
195
|
+
between it and ``ENDEXEC``, which may share a line with either. Every
|
|
196
|
+
other utility statement (``RUNSTATS``, ``REORG``, ``MODIFY RECOVERY``, ...)
|
|
197
|
+
is skipped, so options such as ``UPDATE ALL`` or ``DELETE AGE(90)`` are
|
|
198
|
+
not reported as DML.
|
|
199
|
+
"""
|
|
200
|
+
results: list[tuple[str, str]] = []
|
|
201
|
+
in_exec_sql = False
|
|
202
|
+
for raw_line in split_source_lines(text):
|
|
203
|
+
rest = raw_line.strip()
|
|
204
|
+
while rest:
|
|
205
|
+
if not in_exec_sql:
|
|
206
|
+
start = _EXEC_SQL_RE.search(rest)
|
|
207
|
+
if start is None:
|
|
208
|
+
break
|
|
209
|
+
results.append(("EXEC SQL", _KW_CATEGORY["EXEC SQL"]))
|
|
210
|
+
in_exec_sql = True
|
|
211
|
+
rest = rest[start.end() :]
|
|
212
|
+
continue
|
|
213
|
+
end = _ENDEXEC_RE.search(rest)
|
|
214
|
+
body = rest if end is None else rest[: end.start()]
|
|
215
|
+
results.extend(detect_sql_in_text(body))
|
|
216
|
+
if end is None:
|
|
217
|
+
break
|
|
218
|
+
in_exec_sql = False
|
|
219
|
+
rest = rest[end.end() :]
|
|
220
|
+
return results
|
|
221
|
+
|
|
222
|
+
|
|
181
223
|
def detect_sql_in_dd(dd: DDStatement) -> list[tuple[str, str]]:
|
|
182
224
|
"""Scan a single DD statement's inline data for SQL.
|
|
183
225
|
|
|
@@ -196,7 +238,10 @@ def detect_sql_in_step(step: Step) -> SQLDetectionResult:
|
|
|
196
238
|
the inline data of every DD statement.
|
|
197
239
|
In-stream data of a program in :data:`NON_SQL_CONTROL_PROGRAMS` is not
|
|
198
240
|
scanned, and in a ``SYSTSIN`` DD (TSO commands) only DB2 subcommands such
|
|
199
|
-
as ``BIND`` count.
|
|
241
|
+
as ``BIND`` count. For a program in
|
|
242
|
+
:data:`DB2_UTILITY_CONTROL_PROGRAMS` only ``EXEC SQL ... ENDEXEC``
|
|
243
|
+
utility statements are scanned
|
|
244
|
+
(:func:`detect_sql_in_utility_statements`).
|
|
200
245
|
"""
|
|
201
246
|
result = SQLDetectionResult(
|
|
202
247
|
step_name=step.name,
|
|
@@ -204,15 +249,21 @@ def detect_sql_in_step(step: Step) -> SQLDetectionResult:
|
|
|
204
249
|
is_db2_program=is_db2_program(step.program)
|
|
205
250
|
or bool(step.procedure and step.procedure.upper() in DB2_PROCEDURES),
|
|
206
251
|
)
|
|
207
|
-
|
|
252
|
+
program = step.program.upper() if step.program else ""
|
|
253
|
+
if program in NON_SQL_CONTROL_PROGRAMS:
|
|
208
254
|
return result
|
|
255
|
+
utility_control = program in DB2_UTILITY_CONTROL_PROGRAMS
|
|
209
256
|
is_tso_input = False
|
|
210
257
|
for dd in step.dd_statements:
|
|
211
258
|
# An unnamed DD is a concatenation member of the last named one, so
|
|
212
259
|
# ``// DD *`` after ``//SYSTSIN DD ...`` is TSO input too.
|
|
213
260
|
if dd.name:
|
|
214
261
|
is_tso_input = dd.name.upper() == _TSO_COMMAND_DD
|
|
215
|
-
|
|
262
|
+
if utility_control:
|
|
263
|
+
found = detect_sql_in_utility_statements(dd.inline_data or "")
|
|
264
|
+
else:
|
|
265
|
+
found = detect_sql_in_dd(dd)
|
|
266
|
+
for kw, cat in found:
|
|
216
267
|
if is_tso_input and cat not in _TSO_SQL_CATEGORIES:
|
|
217
268
|
continue
|
|
218
269
|
result.sql_statements.append((kw, cat, dd.name))
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import builtins
|
|
11
11
|
import errno
|
|
12
12
|
import re
|
|
13
|
+
from collections.abc import Sequence
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from typing import Any
|
|
15
16
|
|
|
@@ -94,21 +95,56 @@ def jcl_value_text(value: Any) -> str:
|
|
|
94
95
|
return str(value)
|
|
95
96
|
|
|
96
97
|
|
|
97
|
-
def
|
|
98
|
+
def _continues_on_next_record(line: str) -> bool:
|
|
99
|
+
"""True if a 72-character ``//`` statement record leaves its operand open
|
|
100
|
+
at column 71: a trailing comma, or an unclosed quoted string."""
|
|
101
|
+
if not line.startswith("//") or line.startswith("//*"):
|
|
102
|
+
return False
|
|
103
|
+
return line[70] == "," or line[:71].count("'") % 2 == 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_continuation_record(line: str) -> bool:
|
|
107
|
+
"""True if *line* is a ``//`` record with a blank name field and operands."""
|
|
108
|
+
return line.startswith("// ") and bool(line[3:].strip())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _has_column_72_indicator(line: str, next_line: str | None) -> bool:
|
|
112
|
+
"""True if column 72 of a 72-character record is a continuation indicator.
|
|
113
|
+
|
|
114
|
+
Such a record is a fixed-format card whose sequence field was trimmed.
|
|
115
|
+
A blank column 71 says so on its own. With column 71 non-blank, the
|
|
116
|
+
record could equally be an editor line whose last character is content,
|
|
117
|
+
so column 72 counts as the indicator only when the operand is left open
|
|
118
|
+
at column 71 (trailing comma or unclosed quote) *and* the next record,
|
|
119
|
+
*next_line*, is a continuation (issue #80).
|
|
120
|
+
"""
|
|
121
|
+
if len(line) != 72 or line[71].isspace():
|
|
122
|
+
return False
|
|
123
|
+
if line[70] == " ":
|
|
124
|
+
return True
|
|
125
|
+
return (
|
|
126
|
+
next_line is not None
|
|
127
|
+
and _continues_on_next_record(line)
|
|
128
|
+
and _is_continuation_record(next_line)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_continued_card_image(line: str, next_line: str | None = None) -> bool:
|
|
98
133
|
"""Return ``True`` if raw record *line* is a card image continued in column 72.
|
|
99
134
|
|
|
100
135
|
Exactly the records :func:`normalize_jcl_line` takes a non-blank column-72
|
|
101
|
-
continuation indicator off
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
136
|
+
continuation indicator off (*next_line* is the raw record after *line*,
|
|
137
|
+
as there). For such a record the statement text fills columns 1-71, so
|
|
138
|
+
blanks up to column 71 are known to be coded -- which matters when a
|
|
139
|
+
quoted string is continued: it runs through column 71 and its trailing
|
|
140
|
+
blanks are part of the value (issue #65).
|
|
105
141
|
"""
|
|
106
|
-
if len(line) >= 80
|
|
142
|
+
if len(line) >= 80:
|
|
107
143
|
return not line[71].isspace()
|
|
108
|
-
return
|
|
144
|
+
return _has_column_72_indicator(line, next_line)
|
|
109
145
|
|
|
110
146
|
|
|
111
|
-
def normalize_jcl_line(line: str) -> str:
|
|
147
|
+
def normalize_jcl_line(line: str, next_line: str | None = None) -> str:
|
|
112
148
|
"""
|
|
113
149
|
Normalize JCL line by removing extra whitespace and handling continuations.
|
|
114
150
|
|
|
@@ -123,13 +159,18 @@ def normalize_jcl_line(line: str) -> str:
|
|
|
123
159
|
indicating a true mainframe fixed-width record. Lines shorter than 80
|
|
124
160
|
characters are assumed to come from a modern text editor where all
|
|
125
161
|
characters are actual JCL content and must not be truncated — with one
|
|
126
|
-
exception: a record of exactly 72 characters whose column
|
|
127
|
-
|
|
128
|
-
sequence field trimmed
|
|
129
|
-
|
|
162
|
+
exception: a record of exactly 72 characters whose column 72 is a
|
|
163
|
+
continuation indicator is a fixed-format record that had its trailing
|
|
164
|
+
sequence field trimmed. That is the case when column 71 is blank, or when
|
|
165
|
+
column 71 leaves the operand open (a trailing comma or an unclosed quoted
|
|
166
|
+
string) and *next_line*, the raw record after *line*, is a continuation
|
|
167
|
+
record (``//`` and a blank name field). Without *next_line* only the
|
|
168
|
+
blank-column-71 case applies. Use :func:`normalize_jcl_lines` for a whole
|
|
169
|
+
source.
|
|
130
170
|
|
|
131
171
|
Args:
|
|
132
172
|
line: Raw JCL line
|
|
173
|
+
next_line: The raw record after *line*, if any
|
|
133
174
|
|
|
134
175
|
Returns:
|
|
135
176
|
Normalized line
|
|
@@ -138,10 +179,7 @@ def normalize_jcl_line(line: str) -> str:
|
|
|
138
179
|
# column-72 continuation indicator (0-indexed 71). Only do this for full
|
|
139
180
|
# 80-column mainframe records; shorter lines are modern text-editor JCL
|
|
140
181
|
# where every character is content.
|
|
141
|
-
if len(line) >= 80:
|
|
142
|
-
line = line[:71]
|
|
143
|
-
elif len(line) == 72 and line[70] == " " and not line[71].isspace():
|
|
144
|
-
# Sequence field trimmed away, continuation indicator left behind.
|
|
182
|
+
if len(line) >= 80 or _has_column_72_indicator(line, next_line):
|
|
145
183
|
line = line[:71]
|
|
146
184
|
|
|
147
185
|
# Remove trailing whitespace but preserve leading spaces for continuation detection
|
|
@@ -153,6 +191,23 @@ def normalize_jcl_line(line: str) -> str:
|
|
|
153
191
|
return line
|
|
154
192
|
|
|
155
193
|
|
|
194
|
+
def normalize_jcl_lines(raw_lines: Sequence[str]) -> list[str]:
|
|
195
|
+
""":func:`normalize_jcl_line` every record of *raw_lines*, with lookahead."""
|
|
196
|
+
return [
|
|
197
|
+
normalize_jcl_line(line, raw_lines[i + 1] if i + 1 < len(raw_lines) else None)
|
|
198
|
+
for i, line in enumerate(raw_lines)
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
#: Longest value a symbol substitution may produce: a resolved SET value, a
|
|
203
|
+
#: pre-resolved SET table entry, or the characters one ``substitute_symbols()``
|
|
204
|
+
#: call splices in. A safety limit against chained symbols that double at each
|
|
205
|
+
#: step (``SET A2='&A1&A1'`` ...), deliberately far above the z/OS 255-character
|
|
206
|
+
#: symbol limit so every currently accepted job still parses. Past it the text
|
|
207
|
+
#: is left unresolved with a warning, or the strict paths raise.
|
|
208
|
+
MAX_SYMBOL_VALUE_LENGTH: int = 65536
|
|
209
|
+
|
|
210
|
+
|
|
156
211
|
#: Largest PROC / INCLUDE library member the loaders will read. Members are
|
|
157
212
|
#: named by the JCL being parsed, so an oversized file is refused (read as
|
|
158
213
|
#: missing, with a warning) rather than pulled into memory whole.
|
|
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "jcl-parser"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.3"
|
|
8
8
|
description = "Parse IBM JCL (Job Control Language) scripts into Python objects"
|
|
9
9
|
authors = [
|
|
10
|
-
{name = "
|
|
10
|
+
{name = "Samuel Dion", email = "sam94dion@gmail.com"}
|
|
11
11
|
]
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
license = {text = "AGPL-3.0-or-later"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|