feat: UNSTRING解析增强 + 跨FD数值统一 + 文件I/O模块
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"""COBOL 文件 I/O:DISPLAY/COMP/COMP-3 pack/unpack + 文件读写"""
|
||||
|
||||
import struct
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 存储长度 ──
|
||||
|
||||
|
||||
def get_storage_length(field: dict) -> int:
|
||||
"""返回字段在文件中的字节长度"""
|
||||
pi = field.get('pic_info', {})
|
||||
digits = pi.get('digits', 0)
|
||||
usage = field.get('usage')
|
||||
if not usage or usage == 'DISPLAY':
|
||||
l = pi.get('length')
|
||||
if l:
|
||||
return l
|
||||
return digits + pi.get('decimal', 0) or 1
|
||||
elif usage in ('COMP', 'BINARY'):
|
||||
if digits <= 2:
|
||||
return 1
|
||||
elif digits <= 4:
|
||||
return 2
|
||||
elif digits <= 9:
|
||||
return 4
|
||||
else:
|
||||
return 8
|
||||
elif usage in ('COMP-3', 'PACKED-DECIMAL'):
|
||||
return (digits + 2) // 2
|
||||
else:
|
||||
raise ValueError(f"Unsupported USAGE: {usage}")
|
||||
|
||||
|
||||
# ── pack / unpack ──
|
||||
|
||||
|
||||
def _default_value(field: dict) -> str:
|
||||
"""字段值为空/缺失时的默认值"""
|
||||
pi = field.get('pic_info', {})
|
||||
usage = field.get('usage')
|
||||
if not usage or usage == 'DISPLAY':
|
||||
total = pi.get('length') or (pi.get('digits', 0) + pi.get('decimal', 0)) or 1
|
||||
return ' ' * total
|
||||
digits = pi.get('digits', 0)
|
||||
return '0' * digits
|
||||
|
||||
|
||||
def pack_value(value: str, field: dict) -> bytes:
|
||||
"""将 JSON 字符串值编码为二进制文件表示"""
|
||||
if not value or value.strip() == '':
|
||||
value = _default_value(field)
|
||||
usage = field.get('usage')
|
||||
pi = field.get('pic_info', {})
|
||||
ptype = pi.get('type', 'unknown')
|
||||
digits = pi.get('digits', 0)
|
||||
signed = pi.get('signed', False)
|
||||
|
||||
if not usage or usage == 'DISPLAY':
|
||||
total = pi.get('length') or (digits + pi.get('decimal', 0)) or 1
|
||||
if ptype in ('numeric', 'numeric-edited'):
|
||||
s = str(value).zfill(total)
|
||||
else:
|
||||
s = str(value).ljust(total)
|
||||
return s.encode('utf-8')[:total]
|
||||
|
||||
int_val = int(str(value).strip())
|
||||
|
||||
if usage in ('COMP', 'BINARY'):
|
||||
size = get_storage_length(field)
|
||||
fmt_map = {1: 'b', 2: 'h', 4: 'i', 8: 'q'}
|
||||
fmt = fmt_map[size]
|
||||
if not signed:
|
||||
fmt = fmt.upper()
|
||||
return struct.pack('<' + fmt, int_val)
|
||||
|
||||
elif usage in ('COMP-3', 'PACKED-DECIMAL'):
|
||||
abs_str = str(abs(int_val)).zfill(digits)
|
||||
nibbles = [int(ch) for ch in abs_str]
|
||||
if not signed:
|
||||
nibbles.append(0xF)
|
||||
elif int_val >= 0:
|
||||
nibbles.append(0xC)
|
||||
else:
|
||||
nibbles.append(0xD)
|
||||
if len(nibbles) % 2 == 1:
|
||||
nibbles.insert(0, 0)
|
||||
buf = bytearray()
|
||||
for i in range(0, len(nibbles), 2):
|
||||
buf.append((nibbles[i] << 4) | nibbles[i + 1])
|
||||
return bytes(buf)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported USAGE: {usage}")
|
||||
|
||||
|
||||
def unpack_value(data: bytes, field: dict) -> str:
|
||||
"""将二进制数据解码为 JSON 字符串值"""
|
||||
usage = field.get('usage')
|
||||
pi = field.get('pic_info', {})
|
||||
digits = pi.get('digits', 0)
|
||||
signed = pi.get('signed', False)
|
||||
|
||||
if not usage or usage == 'DISPLAY':
|
||||
return data.decode('utf-8').rstrip()
|
||||
|
||||
elif usage in ('COMP', 'BINARY'):
|
||||
size = len(data)
|
||||
fmt_map = {1: 'b', 2: 'h', 4: 'i', 8: 'q'}
|
||||
fmt = fmt_map[size]
|
||||
if not signed:
|
||||
fmt = fmt.upper()
|
||||
val = struct.unpack('<' + fmt, data)[0]
|
||||
sign = '-' if val < 0 else ''
|
||||
return f"{sign}{str(abs(val)).zfill(digits)}"
|
||||
|
||||
elif usage in ('COMP-3', 'PACKED-DECIMAL'):
|
||||
nibbles = []
|
||||
for byte in data:
|
||||
nibbles.append((byte >> 4) & 0x0F)
|
||||
nibbles.append(byte & 0x0F)
|
||||
sign = nibbles[-1]
|
||||
nibbles = nibbles[:-1]
|
||||
chars = [str(n) for n in nibbles]
|
||||
num_str = ''.join(chars).lstrip('0') or '0'
|
||||
if signed and sign == 0xD:
|
||||
num_str = '-' + num_str
|
||||
return num_str.zfill(digits)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported USAGE: {usage}")
|
||||
|
||||
|
||||
# ── 文件读写 ──
|
||||
|
||||
|
||||
def compute_record_size(fd_field_dicts: list[dict]) -> int:
|
||||
"""计算 FD 记录的总字节长度"""
|
||||
return sum(get_storage_length(f) for f in fd_field_dicts)
|
||||
|
||||
|
||||
def has_any_binary(fd_field_dicts: list[dict]) -> bool:
|
||||
"""FD 中是否有 COMP/COMP-3 字段"""
|
||||
for f in fd_field_dicts:
|
||||
usage = f.get('usage')
|
||||
if usage and usage not in (None, 'DISPLAY'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def write_input_file(records: list[dict], fd_field_dicts: list[dict],
|
||||
output_path: str, line_sequential: bool = False):
|
||||
"""将记录列表写入 COBOL 输入文件"""
|
||||
with open(output_path, 'wb') as f:
|
||||
for record in records:
|
||||
for field_dict in fd_field_dicts:
|
||||
val = record.get(field_dict['name'], '')
|
||||
packed = pack_value(val, field_dict)
|
||||
f.write(packed)
|
||||
if line_sequential:
|
||||
f.write(b'\n')
|
||||
logger.info(f" wrote {len(records)} records to {output_path}")
|
||||
|
||||
|
||||
def read_output_file(file_path: str, fd_field_dicts: list[dict],
|
||||
line_sequential: bool = False, recording_mode: str = 'F') -> list[dict]:
|
||||
"""从 COBOL 输出文件读取记录"""
|
||||
if recording_mode == 'V':
|
||||
return _read_variable_file(file_path, fd_field_dicts)
|
||||
record_size = compute_record_size(fd_field_dicts)
|
||||
records = []
|
||||
if line_sequential:
|
||||
with open(file_path, 'rb') as f:
|
||||
for raw_line in f:
|
||||
raw_line = raw_line.rstrip(b'\r\n')
|
||||
records.append(_unpack_record(raw_line, fd_field_dicts))
|
||||
else:
|
||||
record_size = compute_record_size(fd_field_dicts)
|
||||
with open(file_path, 'rb') as f:
|
||||
while True:
|
||||
data = f.read(record_size)
|
||||
if not data:
|
||||
break
|
||||
records.append(_unpack_record(data, fd_field_dicts))
|
||||
return records
|
||||
|
||||
|
||||
def _read_variable_file(file_path: str, fd_field_dicts: list[dict]) -> list[dict]:
|
||||
"""读取 RECORDING MODE V 文件。
|
||||
|
||||
GnuCOBOL on Linux 可能写入 RDW 前缀,也可能不写入。
|
||||
先尝试 RDW 方式;如果第一笔的 rec_len 不合理(> 10000),
|
||||
则降级为固定长度读取。
|
||||
"""
|
||||
record_size = compute_record_size(fd_field_dicts)
|
||||
if record_size == 0:
|
||||
return []
|
||||
|
||||
raw = open(file_path, 'rb').read()
|
||||
if len(raw) < 4:
|
||||
return []
|
||||
first_rdw = int.from_bytes(raw[:2], 'little')
|
||||
if first_rdw > 10000 or (first_rdw - 4) > record_size * 2:
|
||||
# 没有 RDW 前缀 → 固定长度读取
|
||||
records = []
|
||||
offset = 0
|
||||
while offset + record_size <= len(raw):
|
||||
records.append(_unpack_record(raw[offset:offset + record_size], fd_field_dicts))
|
||||
offset += record_size
|
||||
return records
|
||||
|
||||
# 正常 RDW 方式
|
||||
records = []
|
||||
offset = 0
|
||||
while offset < len(raw):
|
||||
if offset + 4 > len(raw):
|
||||
break
|
||||
rdw_len = int.from_bytes(raw[offset:offset + 2], 'little')
|
||||
data_len = rdw_len - 4 if rdw_len >= 4 else 0
|
||||
offset += 4
|
||||
if offset + data_len > len(raw):
|
||||
break
|
||||
records.append(_unpack_record(raw[offset:offset + data_len], fd_field_dicts))
|
||||
offset += data_len
|
||||
return records
|
||||
|
||||
|
||||
def _unpack_record(data: bytes, fd_field_dicts: list[dict]) -> dict:
|
||||
"""从字节数据中解包一个记录"""
|
||||
record = {}
|
||||
offset = 0
|
||||
for field_dict in fd_field_dicts:
|
||||
slen = get_storage_length(field_dict)
|
||||
record[field_dict['name']] = unpack_value(data[offset:offset + slen], field_dict)
|
||||
offset += slen
|
||||
return record
|
||||
Reference in New Issue
Block a user