"""cobol-free-format-columns-8-and-12.py - companion to the free-format lesson. `cobc` is not installed on the machine this lesson was written on, so the two COBOL listings have not been compiled. This script does the checking a compiler would otherwise do for the part of the lesson that is about columns, and it replays the payroll arithmetic so every number printed in the article comes from a program that was actually run. 1. Reads code/cobol-free-format-columns-8-and-12.cpy and derives every field's offset and length from its PIC clause, so the layout used here is the one both COBOL programs COPY, not a second hand-typed copy of it. 2. Writes datasets/cobol-free-format-columns-8-and-12.dat: 14 timecards of 36 bytes, LF line endings, no header. 3. Checks the reference format of the FIXED listing line by line: the sequence number area, the indicator area, Area A, Area B, and the right margin. 4. Checks that the FREE listing and the FIXED listing are the same program, by reducing both to a token stream (comments and the format directive removed) and comparing them. 5. Replays PAY-EDIT and prints the DISPLAY lines it should produce. Every label printed is first checked to exist as a literal in both .cob files. Record layout (from the copybook, checked at run time): 1-5 TC-Emp-Id X(5) 6-23 TC-Name X(18) 24-26 TC-Dept X(3) 27-31 TC-Hours 9(3)V99 three digits, implied two decimals 32-36 TC-Rate 9(3)V99 Pay rule: hours over 40.00 are overtime, paid at 1.5 times the hourly rate. Rounding: ROUNDED with no MODE is NEAREST-AWAY-FROM-ZERO in the GnuCOBOL Programmer's Guide; every value here is positive, so that is ROUND_HALF_UP in Python's decimal module. Run from the site folder: python code/cobol-free-format-columns-8-and-12.py Standard library only (Python 3.8+). The names in the data are invented. """ import os import re from decimal import Decimal, ROUND_HALF_UP HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.normpath(os.path.join(HERE, os.pardir)) SLUG = "cobol-free-format-columns-8-and-12" CPY = os.path.join(HERE, SLUG + ".cpy") FREE_COB = os.path.join(HERE, "cobol-free-format.cob") FIXED_COB = os.path.join(HERE, "cobol-free-format-fixed.cob") DAT = os.path.join(ROOT, "datasets", SLUG + ".dat") # (employee id, name, department, hours, hourly rate) CARDS = [ ("10014", "ABBOTT RAY", "MNT", "42.50", "31.40"), ("10023", "BLACKWOOD IMOGEN", "ADM", "40.00", "28.75"), ("10031", "CHAUDHRY NADIA", "MNT", "37.25", "30.10"), ("10046", "DELACROIX HUGO", "SHP", "48.00", "26.55"), ("10052", "ESPOSITO MARCO", "SHP", "40.25", "26.55"), ("10068", "FONSECA RITA", "ADM", "35.00", "33.20"), ("10074", "GREENHALGH OWEN", "MNT", "44.75", "31.40"), ("10089", "HIRANO YUKI", "QLT", "40.00", "34.90"), ("10095", "IVERSEN BRITT", "SHP", "52.50", "26.55"), ("10103", "JELLICOE MAREN", "QLT", "38.50", "34.90"), ("10117", "KOVALENKO PAVLO", "MNT", "40.10", "30.10"), ("10124", "LAMPEDUSA GINA", "ADM", "00.00", "28.75"), ("10138", "MBEKI THANDIWE", "QLT", "45.00", "34.90"), ("10142", "NORDSTROM ERIK", "SHP", "41.75", "26.55"), ] STANDARD_HOURS = Decimal("40.00") # WS-Standard-Hours OVERTIME_FACTOR = Decimal("1.5") # WS-Overtime-Factor CENT = Decimal("0.01") # The fixed listing's reference format, from the GnuCOBOL Manual's -fformat=fixed SEQ = slice(0, 6) # columns 1-6 sequence number area IND = 6 # column 7 indicator area AREA_A = slice(7, 11) # columns 8-11 Area A TEXT_END = 72 # column 72 last column of the program-text area # ------------------------------------------------------------ copybook --- def pic_size(pic): """Character positions a DISPLAY picture occupies: X(18) -> 18, 9(3)V99 -> 5.""" expanded = re.sub(r"(.)\((\d+)\)", lambda m: m.group(1) * int(m.group(2)), pic) return len(expanded.replace("V", "")) def read_copybook(path): """Return (fields, record length) from the copybook.""" text = open(path, encoding="ascii").read() data_lines = [ln for ln in text.splitlines() if ln.strip() and not ln.strip().startswith("*>")] fields, offset = [], 0 for ln in data_lines: m = re.match(r"\s*(\d\d)\s+([A-Za-z0-9-]+)\s*(.*)\.\s*$", ln) assert m, f"cannot parse copybook line: {ln!r}" level, name, rest = m.groups() p = re.fullmatch(r"PIC\s+(\S+)", rest.strip()) if p is None: assert rest.strip() == "", ln # the 01 group item continue size = pic_size(p.group(1)) fields.append((name, offset, size, p.group(1))) offset += size return fields, offset def check_copybook_columns(path): """The copybook must be valid in fixed format as well as free format.""" problems = [] for n, ln in enumerate(open(path, encoding="ascii").read().splitlines(), 1): if not ln.strip(): continue if len(ln.rstrip()) > TEXT_END: problems.append(f"line {n} reaches column {len(ln.rstrip())}") if ln[:6].strip(): problems.append(f"line {n} puts text in the sequence number area") stripped = ln.lstrip() if not stripped.startswith("*>") and (len(ln) - len(stripped)) < 7: problems.append(f"line {n} starts in column {len(ln) - len(stripped) + 1}") return problems # -------------------------------------------------- fixed-format checks --- AREA_A_STARTERS = re.compile( r"^(IDENTIFICATION|ENVIRONMENT|DATA|PROCEDURE)\s+DIVISION|" r"^(INPUT-OUTPUT|FILE|WORKING-STORAGE)\s+SECTION|" r"^(FD|SD)\s|^(01|77)\s|^FILE-CONTROL\.|^PROGRAM-ID\.") def is_paragraph_name(body): """True for a paragraph name: a lone user-defined word ending in a period. A scope terminator written with a period, such as END-IF., looks the same to a lexer but is the tail of a statement and belongs in Area B, so the END- prefix is excluded here. """ m = re.fullmatch(r"([A-Za-z][A-Za-z0-9-]*)\.\s*", body) return bool(m) and not m.group(1).upper().startswith("END-") def check_fixed_format(path): """Check the fixed listing against the column rules. Returns (stats, problems).""" lines = open(path, encoding="ascii").read().splitlines() problems = [] seq_numbered = comments = area_a = area_b = 0 longest = 0 for n, ln in enumerate(lines, 1): if not ln.strip(): continue longest = max(longest, len(ln.rstrip())) if len(ln.rstrip()) > TEXT_END: problems.append(f"line {n} reaches column {len(ln.rstrip())} (past {TEXT_END})") seq = ln[SEQ] if len(ln) < 7: problems.append(f"line {n} is shorter than the indicator area") continue if seq.strip(): if not seq.isdigit(): problems.append(f"line {n} sequence area {seq!r} is not all digits") else: seq_numbered += 1 indicator = ln[IND] if indicator == "*": comments += 1 continue if indicator != " ": problems.append(f"line {n} indicator area holds {indicator!r}") continue text = ln[7:] if not text.strip(): continue indent = len(text) - len(text.lstrip()) body = text.lstrip() starts_area_a = indent < 4 if AREA_A_STARTERS.match(body) or is_paragraph_name(body): if not starts_area_a: problems.append(f"line {n} {body.split()[0]!r} should start in Area A") area_a += 1 else: if starts_area_a: problems.append(f"line {n} statement starts in Area A: {body[:30]!r}") area_b += 1 stats = {"lines": len(lines), "sequence numbered": seq_numbered, "comment lines": comments, "Area A entries": area_a, "Area B entries": area_b, "longest line": longest} return stats, problems def check_free_format(path): """The free listing: the directive, and no dependence on columns.""" lines = open(path, encoding="ascii").read().splitlines() problems = [] if not lines or lines[0] != ">>SOURCE FORMAT IS FREE": problems.append("line 1 is not the free-format directive in column 1") longest = max(len(ln.rstrip()) for ln in lines if ln.strip()) past_72 = sum(1 for ln in lines if len(ln.rstrip()) > TEXT_END) in_col_1 = sum(1 for ln in lines if ln.strip() and not ln.startswith(" ")) stats = {"lines": len(lines), "longest line": longest, "lines past column 72": past_72, "lines starting in column 1": in_col_1} return stats, problems def tokens(path, fixed): """Reduce a listing to its COBOL tokens: no comments, no format directive, and in fixed format no sequence number area and no indicator area.""" out = [] for ln in open(path, encoding="ascii").read().splitlines(): if fixed: if len(ln) < 7: continue if ln[IND] == "*": continue ln = ln[7:] else: if ln.lstrip().startswith("*>") or ln.strip().startswith(">>SOURCE"): continue if not ln.strip(): continue out.extend(ln.split()) return out # ------------------------------------------------------------- helpers --- def ed_hours(d): """PIC ZZ9.99 as a 6-character string.""" assert Decimal(0) <= d < Decimal(1000) return f"{d:.2f}".rjust(6) def ed_pay(d): """PIC ZZZ,ZZ9.99 as a 10-character string.""" assert Decimal(0) <= d < Decimal(1000000) return f"{d:,.2f}".rjust(10) def ed_count(n): """PIC ZZ,ZZ9 as a 6-character string.""" assert 0 <= n <= 99999 return f"{n:,}".rjust(6) SOURCES = {} def label(text): """Return text after confirming it is a literal in BOTH .cob files.""" for path in (FREE_COB, FIXED_COB): src = SOURCES.setdefault(path, open(path, encoding="ascii").read()) assert f'"{text}"' in src, f"{os.path.basename(path)} has no literal {text!r}" return text # ---------------------------------------------------------------- main --- def main(): fields, length = read_copybook(CPY) assert length == 36, length assert [f[0] for f in fields] == ["TC-Emp-Id", "TC-Name", "TC-Dept", "TC-Hours", "TC-Rate"] print(f"copybook: {len(fields)} elementary fields, record length {length} bytes") for name, off, size, pic in fields: print(f" {name:<12} positions {off + 1:>2}-{off + size:<2} PIC {pic}") cpy_problems = check_copybook_columns(CPY) print(f"copybook valid in fixed format as well as free: {not cpy_problems}") for p in cpy_problems: print(" PROBLEM " + p) assert not cpy_problems # ------------------------------------------------------- the data --- sizes = {name: (off, size, pic) for name, off, size, pic in fields} lines = [] for emp, name, dept, hours, rate in CARDS: rec = "" for key, val in (("TC-Emp-Id", emp), ("TC-Name", name), ("TC-Dept", dept), ("TC-Hours", hours), ("TC-Rate", rate)): off, size, pic = sizes[key] if pic.startswith("9"): rec += f"{int(Decimal(val) * 100):0{size}d}" else: rec += f"{val:<{size}}" assert len(rec) == length, (emp, len(rec)) lines.append(rec) with open(DAT, "w", encoding="ascii", newline="\n") as f: for ln in lines: f.write(ln + "\n") print(f"wrote {len(lines)} records of {length} bytes to {os.path.relpath(DAT, ROOT)}") # --------------------------------------------- the reference format --- print("--- fixed listing, checked against the column rules ---") fixed_stats, fixed_problems = check_fixed_format(FIXED_COB) for k, v in fixed_stats.items(): print(f" {k:<18} {v}") for p in fixed_problems: print(" PROBLEM " + p) assert not fixed_problems, fixed_problems print("--- free listing ---") free_stats, free_problems = check_free_format(FREE_COB) for k, v in free_stats.items(): print(f" {k:<24} {v}") for p in free_problems: print(" PROBLEM " + p) assert not free_problems, free_problems fixed_tokens = tokens(FIXED_COB, fixed=True) free_tokens = tokens(FREE_COB, fixed=False) print("--- are the two listings the same program? ---") print(f" tokens in the fixed listing {len(fixed_tokens)}") print(f" tokens in the free listing {len(free_tokens)}") if fixed_tokens != free_tokens: for i, (a, b) in enumerate(zip(fixed_tokens, free_tokens)): if a != b: print(f" first difference at token {i}: {a!r} vs {b!r}") break print(f" identical token streams: {fixed_tokens == free_tokens}") assert fixed_tokens == free_tokens # --------------------------------------------------- run PAY-EDIT --- print("--- expected DISPLAY output of PAY-EDIT (either listing) ---") print(label("ID NAME DPT HOURS GROSS")) cards = with_ot = 0 total_gross = total_ot = Decimal(0) for emp, name, dept, hours, rate in CARDS: h, r = Decimal(hours), Decimal(rate) if h > STANDARD_HOURS: basic_hours, ot_hours = STANDARD_HOURS, h - STANDARD_HOURS with_ot += 1 else: basic_hours, ot_hours = h, Decimal(0) basic_pay = (basic_hours * r).quantize(CENT, rounding=ROUND_HALF_UP) ot_pay = (ot_hours * r * OVERTIME_FACTOR).quantize(CENT, rounding=ROUND_HALF_UP) gross = basic_pay + ot_pay cards += 1 total_gross += gross total_ot += ot_pay print(f"{emp} {name:<18} {dept} {ed_hours(h)} {ed_pay(gross)}") print(label("Timecards read ") + ed_count(cards)) print(label("With overtime ") + ed_count(with_ot)) print(label("Overtime pay ") + ed_pay(total_ot)) print(label("Total gross pay ") + ed_pay(total_gross)) assert cards == len(CARDS) print(f"--- companion cross-checks: {cards} cards, {with_ot} with overtime, " f"overtime {total_ot}, gross {total_gross} ---") if __name__ == "__main__": main()