jcl-parser 2.0.1__tar.gz → 2.0.2__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.
Files changed (75) hide show
  1. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/PKG-INFO +1 -1
  2. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jcl_parser.egg-info/PKG-INFO +1 -1
  3. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/__init__.py +1 -1
  4. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/_job_builder.py +12 -2
  5. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/_model_factories.py +56 -6
  6. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/_post_parse.py +67 -20
  7. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/_source_scanner.py +6 -3
  8. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/explosion.py +69 -23
  9. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/parser.py +12 -6
  10. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/utils.py +71 -16
  11. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/pyproject.toml +1 -1
  12. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/LICENSE +0 -0
  13. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/README.md +0 -0
  14. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jcl_parser.egg-info/SOURCES.txt +0 -0
  15. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jcl_parser.egg-info/dependency_links.txt +0 -0
  16. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jcl_parser.egg-info/entry_points.txt +0 -0
  17. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jcl_parser.egg-info/requires.txt +0 -0
  18. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jcl_parser.egg-info/top_level.txt +0 -0
  19. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/__main__.py +0 -0
  20. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/_statement_parser.py +0 -0
  21. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/builtin_procs.py +0 -0
  22. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/cli.py +0 -0
  23. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/condition_parser.py +0 -0
  24. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/continuation_handler.py +0 -0
  25. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/dataset_lifecycle.py +0 -0
  26. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/errors.py +0 -0
  27. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/include_loader.py +0 -0
  28. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/jcl_file_type.py +0 -0
  29. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/jes_statements.py +0 -0
  30. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/lint/__init__.py +0 -0
  31. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/lint/base.py +0 -0
  32. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/lint/config.py +0 -0
  33. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/lint/rules.py +0 -0
  34. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/lint/runner.py +0 -0
  35. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/metrics.py +0 -0
  36. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/model.py +0 -0
  37. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/parameter_ast.py +0 -0
  38. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/parameter_validators.py +0 -0
  39. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/plugins/__init__.py +0 -0
  40. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/plugins/base.py +0 -0
  41. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/proc_loader.py +0 -0
  42. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/profiling.py +0 -0
  43. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/__init__.py +0 -0
  44. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/_control.py +0 -0
  45. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/_invoked.py +0 -0
  46. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/adrdssu.py +0 -0
  47. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/base.py +0 -0
  48. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/binder.py +0 -0
  49. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/bpxbatch.py +0 -0
  50. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/compilers.py +0 -0
  51. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/db2_batch.py +0 -0
  52. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/dfsrrc00.py +0 -0
  53. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/dsnutilb.py +0 -0
  54. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/ftp.py +0 -0
  55. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/icetool.py +0 -0
  56. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/idcams.py +0 -0
  57. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/iebcompr.py +0 -0
  58. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/iebdg.py +0 -0
  59. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/iebgener.py +0 -0
  60. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/iebptpch.py +0 -0
  61. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/iebupdte.py +0 -0
  62. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/iefbr14.py +0 -0
  63. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/iehinitt.py +0 -0
  64. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/iehlist.py +0 -0
  65. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/iehprogm.py +0 -0
  66. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/ikjeft01.py +0 -0
  67. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/irxjcl.py +0 -0
  68. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/registry.py +0 -0
  69. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/program_profiles/sort.py +0 -0
  70. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/py.typed +0 -0
  71. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/rc_analysis.py +0 -0
  72. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/sql_detector.py +0 -0
  73. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/system_symbols.py +0 -0
  74. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/jclparser/validators.py +0 -0
  75. {jcl_parser-2.0.1 → jcl_parser-2.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jcl-parser
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
5
5
  Author-email: JCL Parser Team <team@jclparser.dev>
6
6
  License: AGPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jcl-parser
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: Parse IBM JCL (Job Control Language) scripts into Python objects
5
5
  Author-email: JCL Parser Team <team@jclparser.dev>
6
6
  License: AGPL-3.0-or-later
@@ -20,7 +20,7 @@ application, for example::
20
20
  import logging as _logging
21
21
 
22
22
  # Package metadata
23
- __version__ = "2.0.1" # x-release-please-version
23
+ __version__ = "2.0.2" # 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]] = []
@@ -437,7 +440,14 @@ class JobBuilder:
437
440
  symbols = self.job.set_statements if self.job else self.pending_sets
438
441
 
439
442
  def _record(symbol: str, value: str) -> None:
440
- resolved = _resolve_set_value(value, symbols)
443
+ resolved = _resolve_set_value(
444
+ value,
445
+ symbols,
446
+ symbol=symbol,
447
+ line_number=stmt.line_number,
448
+ strict=self.strict,
449
+ warnings=self._warnings_sink(),
450
+ )
441
451
  if procedure is not None:
442
452
  procedure.set_statements[symbol] = resolved
443
453
  elif self.job:
@@ -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(value: str, known_symbols: dict[str, str]) -> str:
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 _replacer(m: re.Match[str]) -> str:
59
+ def _replacement(m: re.Match[str]) -> str:
42
60
  if m.group(0) == "&&":
43
61
  return "&&"
44
- sym = m.group(1).upper()
45
- return known_symbols.get(sym, m.group(0))
62
+ return known_symbols.get(m.group(1).upper(), m.group(0))
46
63
 
47
- return _SET_SYMBOL_PATTERN.sub(_replacer, value)
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
- from .explosion import SymbolScope, substitute_symbols
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
- _new_val = substitute_symbols(
327
- _val,
328
- _scope_for_val,
329
- allow_undefined=True,
330
- collapse_double_amp=False,
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 _scope_for_table(table: dict[str, str]) -> SymbolScope:
341
- """Build the substitution scope for SET symbol *table* (see resolve_set_symbols)."""
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 not _has_unresolved_symbol_ref(v, ignore=is_system_symbol_name)
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
- normalize_jcl_line,
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 = [normalize_jcl_line(line) for line in raw_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(raw_lines[orig])
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 nested symbol values may expand to within one substitution.
57
- # Fan-out over distinct symbols (``A='&B&B&B'``, ``B='&C&C&C'`` ...) grows
58
- # exponentially with depth even without a cycle, so depth alone is no bound.
59
- _MAX_NESTED_EXPANSION_CHARS = 1_000_000
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 nested expansion may still produce.
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=_budget,
380
+ _budget=budget,
365
381
  ),
366
382
  value,
367
383
  )
368
- _budget[0] -= len(value)
369
- if _budget[0] < 0:
370
- raise JCLStructureError(
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
- return _SYMBOL_SUB_PATTERN.sub(
426
- _make_replacer(
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
- text,
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(
@@ -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(scanned: ScannedSource, config: ParserConfig | None) -> 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(dialect=config.dialect if config is not None else "standard")
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(job: Job, expanded_includes: list[str]) -> None:
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:
@@ -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 is_continued_card_image(line: str) -> bool:
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. For such a record the statement text fills
102
- columns 1-71, so blanks up to column 71 are known to be coded -- which
103
- matters when a quoted string is continued: it runs through column 71
104
- and its trailing blanks are part of the value (issue #65).
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 or (len(line) == 72 and line[70] == " "):
142
+ if len(line) >= 80:
107
143
  return not line[71].isspace()
108
- return False
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 71 is blank and
127
- whose column 72 is not is a fixed-format record that had its trailing
128
- sequence field trimmed, so its column-72 character is a continuation
129
- indicator rather than content.
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,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jcl-parser"
7
- version = "2.0.1"
7
+ version = "2.0.2"
8
8
  description = "Parse IBM JCL (Job Control Language) scripts into Python objects"
9
9
  authors = [
10
10
  {name = "JCL Parser Team", email = "team@jclparser.dev"}
File without changes
File without changes
File without changes
File without changes