diff --git a/src/core/merge_results.py b/src/core/result_payload.py similarity index 64% rename from src/core/merge_results.py rename to src/core/result_payload.py index 2f6c58d..7eb3493 100644 --- a/src/core/merge_results.py +++ b/src/core/result_payload.py @@ -1,17 +1,21 @@ -"""Склейка результатов PDF-процессора в один JSON. +"""Сборка результата PDF-процессора в единый формат публикации. Процессор кладёт по одному json на каждый распознанный документ -({'file_uid': ..., 'error': ..., 'data_ocr': ...}). Здесь они собираются -в единый результат вида: +({'file_uid': ..., 'error': ..., 'data_ocr': ...}). Здесь они заворачиваются +в общий конверт: {'file_id': ..., 'legal_entity_id': ..., 'files': [<содержимое каждого>]} +Форма конверта не зависит от числа документов: одиночный документ проходит +тем же путём и даёт 'files' из одного элемента. Потребителю не нужно знать, +сколько документов распознал процессор, — он всегда читает 'files'. + Содержимое документов не обрабатывается и не валидируется — кладётся как есть. -merge_documents() принимает уже прочитанные объекты и сама ничего не открывает. +build_result() принимает уже прочитанные объекты и сама ничего не открывает. Откуда они взялись — локальная ФС, SMB-шара, S3, http — решает вызывающий: - merge_documents([json.load(f) for f in handles], file_id, legal_entity_id) + build_result([json.load(f) for f in handles], file_id, legal_entity_id) Поэтому модуль не тянет ни config, ни S3, ни NATS, ни smb-клиент. """ @@ -22,15 +26,15 @@ import sys from pathlib import Path -def merge_documents(documents, file_id, legal_entity_id) -> dict: - """Собирает распознанные документы в один результат. +def build_result(documents, file_id, legal_entity_id) -> dict: + """Заворачивает распознанные документы в конверт результата. - documents — любой iterable уже разобранных json-объектов. + documents — любой iterable уже разобранных json-объектов, от одного и больше. Порядок элементов в 'files' сохраняется. """ documents = list(documents) if not documents: - raise ValueError('нечего мержить: не передано ни одного документа') + raise ValueError('нет ни одного документа: процессор ничего не распознал') return { 'file_id': file_id, @@ -51,12 +55,12 @@ def _read_local(paths) -> list: def main(argv=None) -> int: parser = argparse.ArgumentParser( - description='Склеить json-документы процессора в один результат.', + description='Собрать json-документы процессора в единый результат.', ) parser.add_argument( 'documents', nargs='*', - help='пути к json-документам (порядок сохраняется в files[])', + help='пути к json-документам, один и более (порядок сохраняется в files[])', ) parser.add_argument( '-d', '--dir', @@ -80,7 +84,7 @@ def main(argv=None) -> int: try: documents = _read_local(paths) - result = merge_documents(documents, args.file_id, args.legal_entity_id) + result = build_result(documents, args.file_id, args.legal_entity_id) except (OSError, ValueError, json.JSONDecodeError) as e: print(f'ошибка: {e}', file=sys.stderr) return 1 @@ -88,7 +92,7 @@ def main(argv=None) -> int: payload = json.dumps(result, ensure_ascii=False, indent=2) if args.output: Path(args.output).write_text(payload, encoding='utf-8') - print(f'записано {len(result["files"])} документов в {args.output}', file=sys.stderr) + print(f'записано документов: {len(result["files"])} → {args.output}', file=sys.stderr) else: print(payload) return 0 diff --git a/tests/test_merge_results.py b/tests/test_result_payload.py similarity index 50% rename from tests/test_merge_results.py rename to tests/test_result_payload.py index 6c648d8..2330530 100644 --- a/tests/test_merge_results.py +++ b/tests/test_result_payload.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from src.core.merge_results import merge_documents +from src.core.result_payload import build_result DOC_A = { 'file_uid': 'uid-a', @@ -19,8 +19,19 @@ DOC_B = { } -def test_merges_two_documents_into_one_result(): - result = merge_documents([DOC_A, DOC_B], file_id='file-1', legal_entity_id='le-1') +def test_single_document_keeps_the_same_envelope(): + # одиночный документ проходит через ту же функцию ради единого формата + result = build_result([DOC_A], file_id='file-1', legal_entity_id='le-1') + + assert result == { + 'file_id': 'file-1', + 'legal_entity_id': 'le-1', + 'files': [DOC_A], + } + + +def test_several_documents_go_into_one_envelope(): + result = build_result([DOC_A, DOC_B], file_id='file-1', legal_entity_id='le-1') assert result == { 'file_id': 'file-1', @@ -29,14 +40,22 @@ def test_merges_two_documents_into_one_result(): } +def test_envelope_shape_does_not_depend_on_count(): + one = build_result([DOC_A], file_id='f', legal_entity_id='l') + many = build_result([DOC_A, DOC_B], file_id='f', legal_entity_id='l') + + assert list(one) == list(many) + assert isinstance(one['files'], list) + + def test_does_no_io_at_all(monkeypatch): # функция не должна ничего открывать — чтение целиком на вызывающем def explode(*args, **kwargs): - raise AssertionError('merge_documents не должна трогать файловую систему') + raise AssertionError('build_result не должна трогать файловую систему') monkeypatch.setattr('builtins.open', explode) - result = merge_documents([DOC_A], file_id='f', legal_entity_id='l') + result = build_result([DOC_A], file_id='f', legal_entity_id='l') assert result['files'] == [DOC_A] @@ -45,39 +64,40 @@ def test_keeps_document_content_untouched(): # содержимое кладётся как есть: незнакомые поля и вложенность не теряются weird = {'file_uid': 'uid-c', 'unexpected': [1, {'deep': None}], 'кириллица': 'значение'} - result = merge_documents([weird], file_id='file-1', legal_entity_id='le-1') + result = build_result([weird], file_id='file-1', legal_entity_id='le-1') assert result['files'] == [weird] def test_preserves_given_order(): - result = merge_documents([DOC_B, DOC_A], file_id='f', legal_entity_id='l') + result = build_result([DOC_B, DOC_A], file_id='f', legal_entity_id='l') assert [doc['file_uid'] for doc in result['files']] == ['uid-b', 'uid-a'] def test_accepts_any_iterable(): # вызывающий может отдавать генератор, например потоковое чтение с шары - result = merge_documents((d for d in (DOC_A, DOC_B)), file_id='f', legal_entity_id='l') + result = build_result((d for d in (DOC_A, DOC_B)), file_id='f', legal_entity_id='l') assert len(result['files']) == 2 def test_rejects_empty_input(): + # ноль документов — процессор ничего не распознал; это ошибка, а не пустой успех with pytest.raises(ValueError): - merge_documents([], file_id='f', legal_entity_id='l') + build_result([], file_id='f', legal_entity_id='l') -def test_cli_reads_local_files_and_writes_merged(tmp_path): - # CLI — один из вызывающих: локальное чтение живёт здесь, а не в merge_documents +def test_cli_reads_local_files_and_writes_result(tmp_path): + # CLI — один из вызывающих: локальное чтение живёт здесь, а не в build_result a = tmp_path / 'doc_1.json' a.write_text(json.dumps(DOC_A, ensure_ascii=False), encoding='utf-8') b = tmp_path / 'doc_2.json' b.write_text(json.dumps(DOC_B, ensure_ascii=False), encoding='utf-8') - out = tmp_path / 'merged.json' + out = tmp_path / 'result.json' completed = subprocess.run( - [sys.executable, '-m', 'src.core.merge_results', str(a), str(b), + [sys.executable, '-m', 'src.core.result_payload', str(a), str(b), '--file-id', 'file-1', '--legal-entity-id', 'le-1', '-o', str(out)], cwd=Path(__file__).resolve().parent.parent, capture_output=True, @@ -90,3 +110,20 @@ def test_cli_reads_local_files_and_writes_merged(tmp_path): 'legal_entity_id': 'le-1', 'files': [DOC_A, DOC_B], } + + +def test_cli_handles_single_document(tmp_path): + a = tmp_path / 'doc_1.json' + a.write_text(json.dumps(DOC_A, ensure_ascii=False), encoding='utf-8') + out = tmp_path / 'result.json' + + completed = subprocess.run( + [sys.executable, '-m', 'src.core.result_payload', str(a), + '--file-id', 'file-1', '--legal-entity-id', 'le-1', '-o', str(out)], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr + assert json.loads(out.read_text(encoding='utf-8'))['files'] == [DOC_A]