Merge pull request 'Горячая папка: запись прямо в Input, как в старом АРМ; таймаут 20 мин (#26)' (#27) from feat/26-hotfolder-direct-write into main
Reviewed-on: #27
This commit is contained in:
commit
f37a3cd706
12
CLAUDE.md
12
CLAUDE.md
@ -4,12 +4,12 @@
|
|||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: положить файл во «Входящие» как `<job_id>.<ext>`
|
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: положить файл во «Входящие» как `<job_id>.<ext>`
|
||||||
(расширение `original_name` в нижнем регистре; `.part` пишется в `SCAN_TMP_DIR`, по умолчанию `Tmp`, на той же ФС/шаре и
|
(расширение `original_name` в нижнем регистре; пишется **сразу** открытием `wb`, существующий файл перезаписывается — как в
|
||||||
публикуется **без замены**), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый
|
старом АРМ), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый
|
||||||
распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить
|
распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить
|
||||||
горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json`
|
горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json`
|
||||||
(`statementDocument` v1.2: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed`
|
(`statementDocument` v1.2: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed`
|
||||||
(`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`,
|
(`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=1200)`,
|
||||||
старт — запись файла во «Входящие». **Нет доступа к БД.**
|
старт — запись файла во «Входящие». **Нет доступа к БД.**
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
@ -28,9 +28,9 @@
|
|||||||
и golden.
|
и golden.
|
||||||
- Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть
|
- Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть
|
||||||
в fixtures и объяснять; эталоны меняются отдельным PR fixtures.
|
в fixtures и объяснять; эталоны меняются отдельным PR fixtures.
|
||||||
- Горячие папки (раскладка FileWorker): имя `<job_id>.<ext>`, `.part` только в `Tmp`, запись без замены (`os.link` /
|
- Горячие папки (раскладка FileWorker): имя `<job_id>.<ext>`, запись сразу в `Input` открытием `wb` с перезаписью, без
|
||||||
`smbclient.rename`; существующее назначение = «доставлено»). `.part`, ставший целью публикации, снимается самой записью
|
временного файла и `Tmp` — как в старом АРМ (change `hotfolder-direct-write`, ASSUMPTION(OQ-79)); `Tmp` — только у
|
||||||
независимо от флага (№26); любое другое удаление (ответ, входящий файл, `.done`, осиротевшие `.part`) — только при
|
эмулятора. Любое удаление (ответ, входящий файл, `.done`, недописанный при ошибке записи входящий файл) — только при
|
||||||
`SCAN_CLEANUP=true` и только после сохранения `raw/` (не при `TIMEOUT`, `FILE_READ_ERROR`, ошибке записи во входящий каталог или `raw/`). Ошибка
|
`SCAN_CLEANUP=true` и только после сохранения `raw/` (не при `TIMEOUT`, `FILE_READ_ERROR`, ошибке записи во входящий каталог или `raw/`). Ошибка
|
||||||
удаления — одна строка лога, конверт не меняется. Нет расширения у `original_name` → `RECOGNITION_FAILED`.
|
удаления — одна строка лога, конверт не меняется. Нет расширения у `original_name` → `RECOGNITION_FAILED`.
|
||||||
- Набор ответа готов по settle: есть `*.json`, а имена и размеры не менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2;
|
- Набор ответа готов по settle: есть `*.json`, а имена и размеры не менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2;
|
||||||
|
|||||||
56
README.md
56
README.md
@ -28,7 +28,7 @@ files.img_uploaded (NATS, queue group scan-gateway)
|
|||||||
2. ack: files.tabular_parsed, status=processing (до чтения S3 и до ожидания слота)
|
2. ack: files.tabular_parsed, status=processing (до чтения S3 и до ожидания слота)
|
||||||
3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
|
3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
|
||||||
4. файл из S3 (s3.bucket/s3.key)
|
4. файл из S3 (s3.bucket/s3.key)
|
||||||
5. горячая папка: <Input>/<job_id>.<ext> (через <Tmp>/.<job_id>.<ext>.part, без замены)
|
5. горячая папка: <Input>/<job_id>.<ext> (сразу, wb, существующий перезаписывается)
|
||||||
6. опрос <Output>/<job_id>/ каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда
|
6. опрос <Output>/<job_id>/ каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда
|
||||||
не менялся SCAN_SETTLE_SECONDS
|
не менялся SCAN_SETTLE_SECONDS
|
||||||
7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации)
|
7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации)
|
||||||
@ -60,19 +60,15 @@ ack (с `attempt` повторного запроса); файл в ИС вто
|
|||||||
`original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII
|
`original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII
|
||||||
(`aep_5.PDF` → `<job_id>.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не
|
(`aep_5.PDF` → `<job_id>.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не
|
||||||
попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку.
|
попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку.
|
||||||
- Запись атомарна и идёт **без замены**: сначала временный файл `.<job_id>.<ext>.part` в служебном каталоге
|
- Запись — **сразу в `Input`**, как в старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`):
|
||||||
`<SCAN_TMP_DIR>` (по умолчанию `Tmp`), затем он публикуется в `Input` под настоящим именем. `Tmp` лежит рядом с `Input`
|
файл открывается на запись с усечением (`wb`) и пишется целиком; временного файла, служебного каталога и
|
||||||
и `Output`, на той же ФС/шаре: публикация атомарна, а ИС не может забрать недописанный файл (временных файлов в
|
переименования нет. В режиме `smb` — `smbclient.open_file(<Input>\<имя>, "wb")`: файл открыт монопольно, пока идёт
|
||||||
`Input` нет — решение владельца 06.10.2026, у FileWorker `.tmp` лежит рядом с назначением). В режиме `local` публикация —
|
запись, и ИС его не прочитает (ASSUMPTION(OQ-79) — так работал старый АРМ). Если `Input/<job_id>.<ext>` уже существует
|
||||||
жёсткая ссылка `.part` → `Input/<имя>` и удаление `.part`; на файловой системе без жёстких ссылок
|
(повтор задания ядром после потери ack в другом процессе), он **перезаписывается** содержимым объекта S3, задание
|
||||||
(`EPERM`/`EXDEV`/`ENOTSUP`/`EOPNOTSUPP` от самой ссылки) — `os.replace`, если назначения ещё нет (решение №17).
|
продолжается опросом `Output/<job_id>/`. Ошибка посреди записи → `RECOGNITION_FAILED`; недописанный файл снимается
|
||||||
`.part` создаётся заново: остаток прежней записи удаляется (ошибка удаления подавляется), файл открывается с `O_EXCL`;
|
только при `SCAN_CLEANUP=true` (иначе остаётся в `Input`). Каталог `Input` создаётся при необходимости (без
|
||||||
поверх оставшегося `.part` пишем только если это не жёсткая ссылка (`st_nlink == 1`) — иначе исключение (решение №32),
|
родителей; отказ в правах при существующем каталоге — не ошибка), корень горячей папки сам не создаётся: нет корня —
|
||||||
чтобы не испортить уже опубликованный файл. В режиме `smb` — `smbclient.rename` (поднимает `FileExistsError`, если
|
`RECOGNITION_FAILED`.
|
||||||
назначение есть). Если `Input/<job_id>.<ext>` уже существует (повторная публикация задания после потери ack в другом
|
|
||||||
процессе), файл считается доставленным: ничего не пишется, назначение не перезаписывается, `.part` не трогается (при
|
|
||||||
`SCAN_CLEANUP=true` существующий `.part` снимается), задание продолжается опросом `Output/<job_id>/` (решение №35). Каталоги `Input` и `Tmp`
|
|
||||||
создаются при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`.
|
|
||||||
- Ответ ИС — каталог `<SCAN_OUTPUT_DIR>/<job_id>/`: ИС кладёт туда **все** распознанные документы задания одновременно,
|
- Ответ ИС — каталог `<SCAN_OUTPUT_DIR>/<job_id>/`: ИС кладёт туда **все** распознанные документы задания одновременно,
|
||||||
по одному `*.json` на файл (`Output/<job_id>/*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один
|
по одному `*.json` на файл (`Output/<job_id>/*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один
|
||||||
ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть
|
ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть
|
||||||
@ -96,24 +92,19 @@ ack (с `attempt` повторного запроса); файл в ИС вто
|
|||||||
- **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в
|
- **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в
|
||||||
`raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС).
|
`raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС).
|
||||||
- **Что удаляется** (всё, что есть): **всё содержимое** каталога `Output/<job_id>/` (не только `*.json`) и сам каталог,
|
- **Что удаляется** (всё, что есть): **всё содержимое** каталога `Output/<job_id>/` (не только `*.json`) и сам каталог,
|
||||||
`Input/<job_id>.<ext>`, `Input/<job_id>.<ext>.done` (след эмулятора), `Tmp/.<job_id>.<ext>.part`. Отсутствующий файл
|
`Input/<job_id>.<ext>`, `Input/<job_id>.<ext>.done` (след эмулятора). `Tmp` шлюз не трогает. Отсутствующий файл
|
||||||
или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а
|
или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а
|
||||||
подкаталог внутри `Output/<job_id>/` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки
|
подкаталог внутри `Output/<job_id>/` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки
|
||||||
поднимается и логируется, остальные пункты (входящий файл, `.done`, `.part`) в этом задании не удаляются.
|
поднимается и логируется, остальные пункты (входящий файл, `.done`) в этом задании не удаляются.
|
||||||
- **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во
|
- **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во
|
||||||
входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются.
|
входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются.
|
||||||
- **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт,
|
- **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт,
|
||||||
`metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа.
|
`metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа.
|
||||||
- **`.part` и флаг (решение №26).** `.part`, ставший целью успешной публикации, снимается **самой записью**, независимо
|
- **Флаг управляет всем удалением:** при `SCAN_CLEANUP=false` сервис не удаляет ничего — ни каталог ответа, ни входящий
|
||||||
от `SCAN_CLEANUP` (в `local` — удаление после жёсткой ссылки, ошибка удаления подавляется; в `smb` `.part`
|
файл и `.done`, ни недописанный при ошибке записи входящий файл — и не пишет об этом в лог (решение №27). Остатки
|
||||||
переименовывается во входящий и отдельного удаления не требует): иначе при выключенной очистке на каждое задание
|
`Tmp/.*.part` от версий шлюза до change `hotfolder-direct-write` сервис не трогает — убрать вручную.
|
||||||
оставалась бы лишняя ссылка, а ошибка удаления на шаре с неподтверждёнными правами превращала бы доставленный файл в
|
- Права на SMB: для записи учётной записи шары нужно создание и перезапись файлов в `Input`, для чтения ответа —
|
||||||
`RECOGNITION_FAILED`. **Флаг управляет остатками:** осиротевшие `.part` (оборванная запись, `.part` при уже
|
листинг и чтение `Output`; для очистки — удаление в `Input` и `Output` (включая каталог `<job_id>`). Пока права на удаление не
|
||||||
существующем назначении), каталог ответа, входящий файл и `.done`. При `SCAN_CLEANUP=false` сервис не удаляет ничего из
|
|
||||||
этого и не пишет в лог об осиротевших `.part` (решение №27): они накапливаются в `Tmp` и убираются вручную или
|
|
||||||
включением флага.
|
|
||||||
- Права на SMB: для очистки учётной записи шары нужны права на удаление в `Input`, `Output` (включая каталог
|
|
||||||
`<job_id>`) и `Tmp`; для записи — создание файлов в `Tmp` и `Input` и переименование. Пока права на удаление не
|
|
||||||
подтверждены, оставляйте `SCAN_CLEANUP=false`.
|
подтверждены, оставляйте `SCAN_CLEANUP=false`.
|
||||||
|
|
||||||
### Эмулятор ИС в режиме `local`
|
### Эмулятор ИС в режиме `local`
|
||||||
@ -315,12 +306,11 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
|
|||||||
| `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) |
|
| `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) |
|
||||||
| `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня |
|
| `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня |
|
||||||
| `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня |
|
| `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня |
|
||||||
| `SCAN_TMP_DIR` | `Tmp` | служебный каталог относительно корня (на той же ФС/шаре, что `Input`/`Output`): временный `.<job_id>.<ext>.part` записи входящего файла |
|
|
||||||
| `SCAN_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды |
|
| `SCAN_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды |
|
||||||
| `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output/<job_id>/` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) |
|
| `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output/<job_id>/` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) |
|
||||||
| `SCAN_TIMEOUT_SECONDS` | `300` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`) |
|
| `SCAN_TIMEOUT_SECONDS` | `1200` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`; 20 мин — в паре со сроком ответа OCR ядра) |
|
||||||
| `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий |
|
| `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий |
|
||||||
| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output/<job_id>/` и каталог, `Input/<job_id>.<ext>` (и `.done`), `Tmp/.<job_id>.<ext>.part`; `false` — ничего не удаляется (кроме `.part`, снятого самой записью); см. «Очистка горячей папки» |
|
| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output/<job_id>/` и каталог, `Input/<job_id>.<ext>` (и `.done`); `false` — ничего не удаляется; см. «Очистка горячей папки» |
|
||||||
| `SCAN_RULES_DIR` | `rules` | каталог справочников нормализатора (`statement_lines.yaml`, `ocr_lookalikes.yaml`, `statement_labels.yaml`), путь от текущего каталога (`api/`); битый справочник — сервис не стартует |
|
| `SCAN_RULES_DIR` | `rules` | каталог справочников нормализатора (`statement_lines.yaml`, `ocr_lookalikes.yaml`, `statement_labels.yaml`), путь от текущего каталога (`api/`); битый справочник — сервис не стартует |
|
||||||
| `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте |
|
| `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте |
|
||||||
| `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) |
|
| `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) |
|
||||||
@ -335,7 +325,8 @@ MinIO и NATS); те же значения подхватываются из `EN
|
|||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SCAN_HOTFOLDER_PATH` | — (обязательна) | корень горячей папки; должен существовать, иначе код выхода 2 |
|
| `SCAN_HOTFOLDER_PATH` | — (обязательна) | корень горячей папки; должен существовать, иначе код выхода 2 |
|
||||||
| `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR`, `SCAN_TMP_DIR` | `Input`, `Output`, `Tmp` | подкаталоги корня (как у шлюза); в `Tmp` эмулятор собирает набор ответа перед rename в `Output/<uuid>/` |
|
| `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR` | `Input`, `Output` | подкаталоги корня (как у шлюза) |
|
||||||
|
| `SCAN_TMP_DIR` | `Tmp` | служебный каталог эмулятора: в нём собирается набор ответа перед rename в `Output/<uuid>/` (шлюз эту переменную не читает) |
|
||||||
| `SCAN_FIXTURES_DIR` | `../../fixtures/ocr` | каталог эталонных пар: исходник (PDF и др.) и ответы ИС (`aep_N.json`, `aep_N_<суффикс>.json`); пара ищется по sha256 содержимого входящего файла; путь от `api/` |
|
| `SCAN_FIXTURES_DIR` | `../../fixtures/ocr` | каталог эталонных пар: исходник (PDF и др.) и ответы ИС (`aep_N.json`, `aep_N_<суффикс>.json`); пара ищется по sha256 содержимого входящего файла; путь от `api/` |
|
||||||
| `SCAN_EMULATOR_DEFAULT` | пусто | основа имени набора (например, `aep_6`), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка `INVALID_DOCUMENT` |
|
| `SCAN_EMULATOR_DEFAULT` | пусто | основа имени набора (например, `aep_6`), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка `INVALID_DOCUMENT` |
|
||||||
| `SCAN_EMULATOR_DELAY` | `2` | задержка перед выдачей ответа, секунды (>= 0) |
|
| `SCAN_EMULATOR_DELAY` | `2` | задержка перед выдачей ответа, секунды (>= 0) |
|
||||||
@ -508,8 +499,9 @@ make golden-update # пересобрать эталоны нормализ
|
|||||||
- Недописанный набор ответа ИС: защита — settle (`SCAN_SETTLE_SECONDS`), а не разбор содержимого. Если ИС дописывает
|
- Недописанный набор ответа ИС: защита — settle (`SCAN_SETTLE_SECONDS`), а не разбор содержимого. Если ИС дописывает
|
||||||
файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается:
|
файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается:
|
||||||
`response = null`, `parse_error` в `raw/`, ошибка этого файла (`INVALID_FORMAT`) — в `metrics.warnings`.
|
`response = null`, `parse_error` в `raw/`, ошибка этого файла (`INVALID_FORMAT`) — в `metrics.warnings`.
|
||||||
- Запись без замены на файловой системе без жёстких ссылок (`EPERM`/`EXDEV`/`ENOTSUP`): между проверкой назначения и
|
- Запись сразу в `Input` (change `hotfolder-direct-write`): защита от чтения недописанного файла — только монопольное
|
||||||
`os.replace` есть крошечное окно гонки (повтор задания с тем же `job_id` редок, решение №17).
|
открытие `smbclient` на время записи (ASSUMPTION(OQ-79), как в старом АРМ). Если ИС всё же возьмёт файл во время
|
||||||
|
записи, распознавание даст ошибку; откат — вернуть запись через временный файл.
|
||||||
- Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе
|
- Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе
|
||||||
он кладёт ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог. Индекс хешей
|
он кладёт ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог. Индекс хешей
|
||||||
пересчитывается при изменении списка файлов каталога фикстур: изменённый, но не переименованный PDF не
|
пересчитывается при изменении списка файлов каталога фикстур: изменённый, но не переименованный PDF не
|
||||||
|
|||||||
@ -43,19 +43,17 @@ SCAN_HOTFOLDER_PATH=./.hotfolder
|
|||||||
# Подкаталоги корня: «Входящие» (сюда кладётся UUID_имя) и «Исходящие» (оттуда забираются все *.json из <UUID>/)
|
# Подкаталоги корня: «Входящие» (сюда кладётся UUID_имя) и «Исходящие» (оттуда забираются все *.json из <UUID>/)
|
||||||
SCAN_INPUT_DIR=Input
|
SCAN_INPUT_DIR=Input
|
||||||
SCAN_OUTPUT_DIR=Output
|
SCAN_OUTPUT_DIR=Output
|
||||||
# Служебный каталог корня: временный .part пишется сюда и переименовывается во «Входящие» (та же ФС/шара — rename
|
|
||||||
# атомарен, во «Входящих» не бывает недописанных файлов); создаётся при первой записи
|
|
||||||
SCAN_TMP_DIR=Tmp
|
|
||||||
# Период опроса «Исходящих», секунды
|
# Период опроса «Исходящих», секунды
|
||||||
SCAN_POLL_SECONDS=5
|
SCAN_POLL_SECONDS=5
|
||||||
# Набор *.json в <UUID>/ считается готовым, когда имена и размеры файлов не менялись столько секунд (>= 0)
|
# Набор *.json в <UUID>/ считается готовым, когда имена и размеры файлов не менялись столько секунд (>= 0)
|
||||||
SCAN_SETTLE_SECONDS=2
|
SCAN_SETTLE_SECONDS=2
|
||||||
# Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса); повторной отправки нет
|
# Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса; 20 мин — в паре со сроком
|
||||||
SCAN_TIMEOUT_SECONDS=300
|
# ответа OCR ядра); повторной отправки нет
|
||||||
|
SCAN_TIMEOUT_SECONDS=1200
|
||||||
# Сколько заданий обрабатывается одновременно
|
# Сколько заданий обрабатывается одновременно
|
||||||
SCAN_MAX_INFLIGHT=4
|
SCAN_MAX_INFLIGHT=4
|
||||||
# Очистка горячей папки после прочтения ответа (D-2): true — удалить всё содержимое Output/<job_id>/, входящий файл
|
# Очистка горячей папки после прочтения ответа (D-2): true — удалить всё содержимое Output/<job_id>/, входящий файл
|
||||||
# (и его .done), осиротевший .part в Tmp; false (по умолчанию) — ничего не удалять
|
# (и его .done); false (по умолчанию) — ничего не удалять
|
||||||
SCAN_CLEANUP=false
|
SCAN_CLEANUP=false
|
||||||
# Каталог справочников нормализатора OCR (statement_lines.yaml, ocr_lookalikes.yaml, statement_labels.yaml);
|
# Каталог справочников нормализатора OCR (statement_lines.yaml, ocr_lookalikes.yaml, statement_labels.yaml);
|
||||||
# относительный путь — от каталога запуска (api/); битый справочник — сервис не стартует
|
# относительный путь — от каталога запуска (api/); битый справочник — сервис не стартует
|
||||||
@ -70,7 +68,9 @@ SCAN_RULES_DIR=rules
|
|||||||
# --- Эмулятор ИС Сканирование (python -m src.scan_emulator, make emulator; только dev) ---
|
# --- Эмулятор ИС Сканирование (python -m src.scan_emulator, make emulator; только dev) ---
|
||||||
# Эти параметры читает сам эмулятор, сервис шлюза их не использует. Корень горячей папки и подкаталоги
|
# Эти параметры читает сам эмулятор, сервис шлюза их не использует. Корень горячей папки и подкаталоги
|
||||||
# эмулятор берёт из SCAN_HOTFOLDER_PATH / SCAN_INPUT_DIR / SCAN_OUTPUT_DIR выше; набор ответа на задание он собирает
|
# эмулятор берёт из SCAN_HOTFOLDER_PATH / SCAN_INPUT_DIR / SCAN_OUTPUT_DIR выше; набор ответа на задание он собирает
|
||||||
# во временном каталоге SCAN_TMP_DIR (выше) и переименовывает в <Output>/<uuid>/ целиком.
|
# во временном каталоге SCAN_TMP_DIR и переименовывает в <Output>/<uuid>/ целиком (шлюз SCAN_TMP_DIR не читает: файл
|
||||||
|
# во «Входящие» он пишет сразу, как в старом АРМ).
|
||||||
|
SCAN_TMP_DIR=Tmp
|
||||||
# Каталог с фикстурами ответов ИС (aep_N.json); путь от api/ (репозиторий fixtures лежит рядом с workspace)
|
# Каталог с фикстурами ответов ИС (aep_N.json); путь от api/ (репозиторий fixtures лежит рядом с workspace)
|
||||||
SCAN_FIXTURES_DIR=../../fixtures/ocr
|
SCAN_FIXTURES_DIR=../../fixtures/ocr
|
||||||
# Задержка перед выдачей ответа, секунды (имитация работы ИС)
|
# Задержка перед выдачей ответа, секунды (имитация работы ИС)
|
||||||
|
|||||||
@ -83,13 +83,13 @@ class Settings(BaseSettings):
|
|||||||
SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать)
|
SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать)
|
||||||
SCAN_INPUT_DIR: str = "Input"
|
SCAN_INPUT_DIR: str = "Input"
|
||||||
SCAN_OUTPUT_DIR: str = "Output"
|
SCAN_OUTPUT_DIR: str = "Output"
|
||||||
SCAN_TMP_DIR: str = "Tmp" # служебный каталог временных файлов записи (рядом с Input, на той же ФС/шаре)
|
|
||||||
SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих»
|
SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих»
|
||||||
# набор *.json ответа готов, когда имена и размеры не менялись столько секунд; 0 — готов сразу (тесты)
|
# набор *.json ответа готов, когда имена и размеры не менялись столько секунд; 0 — готов сразу (тесты)
|
||||||
SCAN_SETTLE_SECONDS: NonNegativeFloat = 2
|
SCAN_SETTLE_SECONDS: NonNegativeFloat = 2
|
||||||
SCAN_TIMEOUT_SECONDS: PositiveFloat = 300 # потолок ожидания ответа ИС (дополнительно к reply_deadline запроса)
|
# Потолок ожидания ответа ИС (дополнительно к reply_deadline запроса): 20 мин, в паре со сроком ответа OCR ядра
|
||||||
|
SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200
|
||||||
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
|
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
|
||||||
# Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true.
|
# Очистка горячей папки после прочтения набора ответа (D-2): ответ, входящий файл, .done — только при true.
|
||||||
# На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
|
# На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
|
||||||
SCAN_CLEANUP: bool = False
|
SCAN_CLEANUP: bool = False
|
||||||
SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/)
|
SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/)
|
||||||
|
|||||||
@ -18,7 +18,6 @@ def create_hotfolder(settings: Settings) -> HotFolder:
|
|||||||
settings.SCAN_HOTFOLDER_PATH,
|
settings.SCAN_HOTFOLDER_PATH,
|
||||||
settings.SCAN_INPUT_DIR,
|
settings.SCAN_INPUT_DIR,
|
||||||
settings.SCAN_OUTPUT_DIR,
|
settings.SCAN_OUTPUT_DIR,
|
||||||
settings.SCAN_TMP_DIR,
|
|
||||||
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
||||||
cleanup=settings.SCAN_CLEANUP,
|
cleanup=settings.SCAN_CLEANUP,
|
||||||
)
|
)
|
||||||
@ -31,7 +30,6 @@ def create_hotfolder(settings: Settings) -> HotFolder:
|
|||||||
smb.SMB_PATH,
|
smb.SMB_PATH,
|
||||||
settings.SCAN_INPUT_DIR,
|
settings.SCAN_INPUT_DIR,
|
||||||
settings.SCAN_OUTPUT_DIR,
|
settings.SCAN_OUTPUT_DIR,
|
||||||
settings.SCAN_TMP_DIR,
|
|
||||||
login=smb.SMB_LOGIN,
|
login=smb.SMB_LOGIN,
|
||||||
password=smb.SMB_PASSWORD,
|
password=smb.SMB_PASSWORD,
|
||||||
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, D-2, R-4, R-5).
|
"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, D-2, R-4, R-5).
|
||||||
|
|
||||||
Файл кладётся во входящий каталог как `<job_id>.<ext>` (D-1): сначала временный `.<имя>.part` в служебном каталоге
|
Файл кладётся во входящий каталог как `<job_id>.<ext>` (D-1) сразу, открытием `wb` с перезаписью существующего — как в
|
||||||
`Tmp` (на той же ФС/шаре), затем жёсткая ссылка (без замены) во входящий (решение №17). Ответ ИС — набор файлов каталога
|
старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`). Ответ ИС — набор файлов каталога
|
||||||
`<output>/<job_id>/`: ИС кладёт туда все распознанные документы задания, по одному `*.json` на документ (OQ-78).
|
`<output>/<job_id>/`: ИС кладёт туда все распознанные документы задания, по одному `*.json` на документ (OQ-78).
|
||||||
`poll` отдаёт все `*.json` по возрастанию имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров
|
`poll` отдаёт все `*.json` по возрастанию имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров
|
||||||
не менялся `settle_seconds` (`SCAN_SETTLE_SECONDS`, `SettleTracker`). Имена каталогов (`Input`/`Output`/`Tmp`)
|
не менялся `settle_seconds` (`SCAN_SETTLE_SECONDS`, `SettleTracker`). Имена каталогов (`Input`/`Output`) настраиваются,
|
||||||
настраиваются, а не зашиты в код.
|
а не зашиты в код; `Tmp` — служебный каталог эмулятора (сборка набора ответа), шлюз его не использует.
|
||||||
|
|
||||||
Очистка (D-2) удаляет следы задания: ответ, входящий файл, временные файлы — при `SCAN_CLEANUP=true`.
|
Очистка (D-2) удаляет следы задания: ответ, входящий файл и его `.done` — при `SCAN_CLEANUP=true`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import errno
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@ -29,8 +28,6 @@ _ILLEGAL_CHARS = frozenset(':<>"|?*') | frozenset(chr(code) for code in range(0x
|
|||||||
_FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS)
|
_FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS)
|
||||||
# Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z]
|
# Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z]
|
||||||
_EXTENSION_RE = re.compile(r"[0-9a-z]{1,16}")
|
_EXTENSION_RE = re.compile(r"[0-9a-z]{1,16}")
|
||||||
# errno `os.link`, означающие «ФС не умеет жёсткие ссылки» (запасной путь записи — `os.replace`)
|
|
||||||
_NO_HARD_LINKS = (errno.EPERM, errno.EXDEV, errno.ENOTSUP, errno.EOPNOTSUPP)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_name(name: str) -> None:
|
def validate_name(name: str) -> None:
|
||||||
@ -171,7 +168,8 @@ class LocalHotFolder:
|
|||||||
"""Горячая папка в обычном каталоге: dev, compose-том, unit-тесты.
|
"""Горячая папка в обычном каталоге: dev, compose-том, unit-тесты.
|
||||||
|
|
||||||
`root`, `input_dir`, `output_dir`, `tmp_dir`, `answer_dir` и `answer_path` открыты намеренно (R-4): эмулятор
|
`root`, `input_dir`, `output_dir`, `tmp_dir`, `answer_dir` и `answer_path` открыты намеренно (R-4): эмулятор
|
||||||
ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа).
|
ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа);
|
||||||
|
`tmp_dir` нужен только эмулятору (сборка набора ответа), `put` его не использует.
|
||||||
`settle_seconds` и `clock` (монотонные часы) — готовность набора ответа (`SettleTracker`).
|
`settle_seconds` и `clock` (монотонные часы) — готовность набора ответа (`SettleTracker`).
|
||||||
`cleanup_enabled` (D-2) — очистка включена при `SCAN_CLEANUP`.
|
`cleanup_enabled` (D-2) — очистка включена при `SCAN_CLEANUP`.
|
||||||
"""
|
"""
|
||||||
@ -204,76 +202,25 @@ class LocalHotFolder:
|
|||||||
return self.answer_dir(job_id) / f"{job_id}.json"
|
return self.answer_dir(job_id) / f"{job_id}.json"
|
||||||
|
|
||||||
def put(self, name: str, data: bytes) -> None:
|
def put(self, name: str, data: bytes) -> None:
|
||||||
"""Атомарная запись без замены: `.<name>.part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/<name>` и
|
"""Записать `input_dir/<name>` сразу (`wb`, перезапись существующего, как в старом АРМ — решение владельца
|
||||||
удаление `.part` как часть успешной публикации (R-8). Назначение уже есть — файл считается доставленным
|
09.10.2026). `Input` создаётся без родителей, корень — нет (нет корня — `FileNotFoundError`). При ошибке записи
|
||||||
(повтор задания, R-17): ничего не пишется, `.part` снимается только при включённой очистке. Корень не
|
недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется. Ошибка самого
|
||||||
создаётся (нет корня — `FileNotFoundError`)."""
|
открытия ничего не удаляет: на месте может лежать целый файл прошлой доставки (I-1)."""
|
||||||
validate_name(name)
|
validate_name(name)
|
||||||
self.tmp_dir.mkdir(exist_ok=True)
|
|
||||||
self.input_dir.mkdir(exist_ok=True)
|
self.input_dir.mkdir(exist_ok=True)
|
||||||
part = self.tmp_dir / f".{name}.part"
|
|
||||||
target = self.input_dir / name
|
target = self.input_dir / name
|
||||||
if target.exists():
|
handle = open(target, "wb") # noqa: SIM115 — открытие вне try: его ошибка не снимает чужой файл (I-1)
|
||||||
# R-17: `.part` от прошлой публикации может быть жёсткой ссылкой на этот файл (удаление после публикации
|
|
||||||
# подавляется, R-8, OQ-144) — писать и даже удалять его незачем, файл уже доставлен
|
|
||||||
self._drop_part(part)
|
|
||||||
return
|
|
||||||
# `.part` создаётся заново (№32): остаток от прерванной записи не должен ронять задание, поэтому ошибка его
|
|
||||||
# удаления подавляется; если он всё же остался, `_open_part` не даст записать «поверх» ссылки на чужой файл
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
part.unlink(missing_ok=True)
|
|
||||||
fd = self._open_part(part)
|
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "wb") as handle:
|
with handle:
|
||||||
handle.write(data)
|
handle.write(data)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
self._drop_part(part)
|
self._drop_partial(target)
|
||||||
raise
|
|
||||||
try:
|
|
||||||
os.link(part, target) # без замены: существующее назначение даёт FileExistsError
|
|
||||||
except FileExistsError:
|
|
||||||
self._drop_part(part)
|
|
||||||
return
|
|
||||||
except OSError as err:
|
|
||||||
# ФС без жёстких ссылок (некоторые тома Docker/сетевые монтирования): запасной путь через replace с
|
|
||||||
# проверкой назначения (окно гонки крошечное, повтор задания с тем же job_id редок). Только для самой
|
|
||||||
# линковки: ошибка записи выше поднимается как есть
|
|
||||||
if err.errno in _NO_HARD_LINKS and not target.exists():
|
|
||||||
self._replace_part(part, target)
|
|
||||||
return
|
|
||||||
self._drop_part(part)
|
|
||||||
raise
|
|
||||||
except BaseException:
|
|
||||||
self._drop_part(part)
|
|
||||||
raise
|
|
||||||
# завершение успешной записи (как rename в старой версии), не управляется флагом очистки:
|
|
||||||
# файл уже доставлен, ошибка удаления .part её не должна отменить (R-8)
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
part.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _open_part(part: Path) -> int:
|
|
||||||
"""Открыть `.part` на запись: новый файл (`O_EXCL`). Если старый не удалился (`FileExistsError`) — усечь его
|
|
||||||
только когда это обычный файл без других ссылок (`st_nlink == 1`); ссылку на опубликованный файл не трогаем,
|
|
||||||
исключение поднимается как есть (R-17)."""
|
|
||||||
try:
|
|
||||||
return os.open(part, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
||||||
except FileExistsError:
|
|
||||||
if os.lstat(part).st_nlink != 1:
|
|
||||||
raise
|
|
||||||
return os.open(part, os.O_WRONLY | os.O_TRUNC)
|
|
||||||
|
|
||||||
def _replace_part(self, part: Path, target: Path) -> None:
|
|
||||||
try:
|
|
||||||
os.replace(part, target)
|
|
||||||
except BaseException:
|
|
||||||
self._drop_part(part)
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _drop_part(self, part: Path) -> None:
|
def _drop_partial(self, path: Path) -> None:
|
||||||
if self.cleanup_enabled:
|
if self.cleanup_enabled:
|
||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
part.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
def cleanup(self, job_id: str, name: str) -> bool:
|
def cleanup(self, job_id: str, name: str) -> bool:
|
||||||
if not self.cleanup_enabled:
|
if not self.cleanup_enabled:
|
||||||
@ -282,7 +229,7 @@ class LocalHotFolder:
|
|||||||
folder = self.answer_dir(job_id)
|
folder = self.answer_dir(job_id)
|
||||||
if folder.is_dir():
|
if folder.is_dir():
|
||||||
shutil.rmtree(folder)
|
shutil.rmtree(folder)
|
||||||
for path in (self.input_dir / name, self.input_dir / f"{name}.done", self.tmp_dir / f".{name}.part"):
|
for path in (self.input_dir / name, self.input_dir / f"{name}.done"):
|
||||||
try:
|
try:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
|||||||
@ -4,10 +4,10 @@ r"""Горячая папка на SMB-шаре платформы (`SCAN_HOTFOL
|
|||||||
в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы
|
в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы
|
||||||
сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения).
|
сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения).
|
||||||
|
|
||||||
Пути — UNC: временный файл `\\<host>\<SMB_PATH>\<tmp_dir>\.<имя>.part` переименовывается (rename без замены)
|
Пути — UNC: входящий файл пишется сразу в `\\<host>\<SMB_PATH>\<input_dir>\<имя>` (`wb`, существующий перезаписывается,
|
||||||
во входящий `\\<host>\<SMB_PATH>\<input_dir>\<имя>` (одна шара — rename атомарен, OQ-79); ответ — все `*.json`
|
как в старом АРМ — решение владельца 09.10.2026, change `hotfolder-direct-write`); ответ — все `*.json` каталога
|
||||||
каталога `\\<host>\<SMB_PATH>\<output_dir>\<job_id>\` (OQ-78), готовность — по стабилизации набора
|
`\\<host>\<SMB_PATH>\<output_dir>\<job_id>\` (OQ-78), готовность — по стабилизации набора (`SettleTracker`).
|
||||||
(`SettleTracker`). Очистка (D-2) удаляет ответы, входящий файл и временные файлы при `SCAN_CLEANUP=true`.
|
Очистка (D-2) удаляет ответы, входящий файл и его `.done` при `SCAN_CLEANUP=true`.
|
||||||
Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся
|
Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся
|
||||||
по `errno == ENOENT`.
|
по `errno == ENOENT`.
|
||||||
"""
|
"""
|
||||||
@ -66,9 +66,9 @@ def build_unc_root(host: str, path: str) -> str:
|
|||||||
|
|
||||||
class SmbHotFolder:
|
class SmbHotFolder:
|
||||||
"""Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у
|
"""Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у
|
||||||
`LocalHotFolder`: те же правила имён, атомарная запись `Tmp\\.<name>.part` + rename без замены в `Input`,
|
`LocalHotFolder`: те же правила имён, запись сразу в `Input` с перезаписью, `Input` создаётся без родителей, корень
|
||||||
`Tmp` и `Input` создаются без родителей, корень не создаётся, готовность набора ответа — по стабилизации
|
не создаётся, готовность набора ответа — по стабилизации (`SettleTracker`). Очистка (D-2) под флагом
|
||||||
(`SettleTracker`). Очистка (D-2) под флагом `SCAN_CLEANUP`."""
|
`SCAN_CLEANUP`."""
|
||||||
|
|
||||||
def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов и готовность, как у LocalHotFolder
|
def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов и готовность, как у LocalHotFolder
|
||||||
self,
|
self,
|
||||||
@ -76,7 +76,6 @@ class SmbHotFolder:
|
|||||||
path: str,
|
path: str,
|
||||||
input_dir: str = "Input",
|
input_dir: str = "Input",
|
||||||
output_dir: str = "Output",
|
output_dir: str = "Output",
|
||||||
tmp_dir: str = "Tmp",
|
|
||||||
*,
|
*,
|
||||||
login: str = "",
|
login: str = "",
|
||||||
password: str = "",
|
password: str = "",
|
||||||
@ -87,7 +86,6 @@ class SmbHotFolder:
|
|||||||
self.root: str = build_unc_root(host, path)
|
self.root: str = build_unc_root(host, path)
|
||||||
self.input_dir: str = ntpath.join(self.root, input_dir)
|
self.input_dir: str = ntpath.join(self.root, input_dir)
|
||||||
self.output_dir: str = ntpath.join(self.root, output_dir)
|
self.output_dir: str = ntpath.join(self.root, output_dir)
|
||||||
self.tmp_dir: str = ntpath.join(self.root, tmp_dir)
|
|
||||||
self._credentials: dict[str, Any] = {"username": login, "password": password} if login else {}
|
self._credentials: dict[str, Any] = {"username": login, "password": password} if login else {}
|
||||||
self._settle = SettleTracker(settle_seconds, clock)
|
self._settle = SettleTracker(settle_seconds, clock)
|
||||||
self.cleanup_enabled = cleanup
|
self.cleanup_enabled = cleanup
|
||||||
@ -102,29 +100,22 @@ class SmbHotFolder:
|
|||||||
return ntpath.join(self.answer_dir(job_id), f"{job_id}.json")
|
return ntpath.join(self.answer_dir(job_id), f"{job_id}.json")
|
||||||
|
|
||||||
def put(self, name: str, data: bytes) -> None:
|
def put(self, name: str, data: bytes) -> None:
|
||||||
"""Записать `.<name>.part` в служебный каталог, затем `smbclient.rename` в `<input>\\<name>` без замены.
|
"""Записать `<input>\\<name>` сразу, открытием `wb` (как в старом АРМ, решение владельца 09.10.2026): временного
|
||||||
Имя проверяется до любого обращения к шаре. Назначение уже есть (повтор задания) — это не ошибка: файл
|
файла и rename нет, существующий файл перезаписывается. `smbclient.open_file` открывает файл монопольно — пока
|
||||||
считается доставленным и не заменяется. В этом случае, как и при любой другой ошибке записи или переименования,
|
запись идёт, ИС его не прочитает (ASSUMPTION(OQ-79)). Имя проверяется до любого обращения к шаре. При ошибке
|
||||||
`.part` снимается только при включённой очистке (`SCAN_CLEANUP`, D-2), иначе остаётся в `Tmp`; ошибка снятия
|
записи недописанный файл снимается только при включённой очистке (`SCAN_CLEANUP`, R-3); ошибка снятия исходную
|
||||||
исходную не маскирует. При успехе `.part` забирает сам rename. Нет корня — `SMBOSError` (ENOENT) от первого
|
не маскирует. Ошибка самого открытия (файл занят ИС, нет прав) ничего не удаляет: недописанного файла нет, а на
|
||||||
`mkdir`.
|
месте может лежать целый файл прошлой доставки (I-1). Нет корня — `SMBOSError` (ENOENT) от `mkdir`.
|
||||||
"""
|
"""
|
||||||
validate_name(name)
|
validate_name(name)
|
||||||
self._ensure_dir(self.tmp_dir)
|
|
||||||
self._ensure_dir(self.input_dir)
|
self._ensure_dir(self.input_dir)
|
||||||
part = ntpath.join(self.tmp_dir, f".{name}.part")
|
target = ntpath.join(self.input_dir, name)
|
||||||
|
fd = smbclient.open_file(target, mode="wb", **self._credentials)
|
||||||
try:
|
try:
|
||||||
with smbclient.open_file(part, mode="wb", **self._credentials) as fd:
|
with fd:
|
||||||
fd.write(data)
|
fd.write(data)
|
||||||
smbclient.rename(part, ntpath.join(self.input_dir, name), **self._credentials)
|
|
||||||
except OSError as err:
|
|
||||||
self._drop_part(part)
|
|
||||||
ntstatus = getattr(err, "ntstatus", None)
|
|
||||||
if _has_errno(err, errno.EEXIST, FileExistsError) or ntstatus == NtStatus.STATUS_OBJECT_NAME_COLLISION:
|
|
||||||
return
|
|
||||||
raise
|
|
||||||
except BaseException:
|
except BaseException:
|
||||||
self._drop_part(part)
|
self._drop_partial(target)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def poll(self, job_id: str) -> list[AnswerFile] | None:
|
def poll(self, job_id: str) -> list[AnswerFile] | None:
|
||||||
@ -166,14 +157,14 @@ class SmbHotFolder:
|
|||||||
"""Число заданий с ненастоявшимся набором (для тестов и диагностики)."""
|
"""Число заданий с ненастоявшимся набором (для тестов и диагностики)."""
|
||||||
return len(self._settle)
|
return len(self._settle)
|
||||||
|
|
||||||
def _drop_part(self, path: str) -> None:
|
def _drop_partial(self, path: str) -> None:
|
||||||
"""Удалить временный файл при ошибке, только если очистка включена (D-2)."""
|
"""Удалить недописанный входящий файл при ошибке записи, только если очистка включена (D-2, R-3)."""
|
||||||
if self.cleanup_enabled:
|
if self.cleanup_enabled:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
smbclient.remove(path, **self._credentials)
|
smbclient.remove(path, **self._credentials)
|
||||||
|
|
||||||
def cleanup(self, job_id: str, name: str) -> bool:
|
def cleanup(self, job_id: str, name: str) -> bool:
|
||||||
"""Удалить следы задания: ответ из `output`, входящий файл, временные файлы. Возвращает `False`, если
|
"""Удалить следы задания: ответ из `output`, входящий файл и его `.done`. Возвращает `False`, если
|
||||||
очистка выключена; `True` при успешной очистке. Ошибки удаления (кроме ENOENT) поднимаются."""
|
очистка выключена; `True` при успешной очистке. Ошибки удаления (кроме ENOENT) поднимаются."""
|
||||||
if not self.cleanup_enabled:
|
if not self.cleanup_enabled:
|
||||||
return False
|
return False
|
||||||
@ -194,7 +185,6 @@ class SmbHotFolder:
|
|||||||
for path in (
|
for path in (
|
||||||
ntpath.join(self.input_dir, name),
|
ntpath.join(self.input_dir, name),
|
||||||
ntpath.join(self.input_dir, f"{name}.done"),
|
ntpath.join(self.input_dir, f"{name}.done"),
|
||||||
ntpath.join(self.tmp_dir, f".{name}.part"),
|
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
smbclient.remove(path, **self._credentials)
|
smbclient.remove(path, **self._credentials)
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и
|
Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и
|
||||||
эмулятор ИС Сканирование (`src.scan_emulator.run` в потоке, `SCAN_EMULATOR_DEFAULT` пуст) работают в процессе
|
эмулятор ИС Сканирование (`src.scan_emulator.run` в потоке, `SCAN_EMULATOR_DEFAULT` пуст) работают в процессе
|
||||||
теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из `api/.env.test` (`make up`), горячая папка — tmp-каталог.
|
теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из `api/.env.test` (`make up`), горячая папка — tmp-каталог.
|
||||||
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, `.part` живёт в `Tmp`), пару фикстур
|
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, пишется прямо в `Input`), пару фикстур
|
||||||
эмулятор находит по sha256 содержимого: в его каталоге фикстур лежат PDF и ответы (реальные `aep_*` из
|
эмулятор находит по sha256 содержимого: в его каталоге фикстур лежат PDF и ответы (реальные `aep_*` из
|
||||||
`fixtures/ocr` и синтетический `ok_doc`).
|
`fixtures/ocr` и синтетический `ok_doc`).
|
||||||
|
|
||||||
@ -16,7 +16,7 @@
|
|||||||
3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`);
|
3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`);
|
||||||
4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`;
|
4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`;
|
||||||
5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора;
|
5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора;
|
||||||
6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_id>/` пусты, `Tmp/` без `.part`.
|
6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_id>/` пусты.
|
||||||
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата
|
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата
|
||||||
из MinIO — схемой `recognition-result`; склеенный сырой ответ в MinIO содержит ровно те файлы, что лежат в каталоге
|
из MinIO — схемой `recognition-result`; склеенный сырой ответ в MinIO содержит ровно те файлы, что лежат в каталоге
|
||||||
ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора
|
ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора
|
||||||
@ -178,10 +178,6 @@ class Roundtrip:
|
|||||||
def input_dir(self) -> Path:
|
def input_dir(self) -> Path:
|
||||||
return self.root / self.settings.SCAN_INPUT_DIR
|
return self.root / self.settings.SCAN_INPUT_DIR
|
||||||
|
|
||||||
@property
|
|
||||||
def tmp_dir(self) -> Path:
|
|
||||||
return self.root / self.settings.SCAN_TMP_DIR
|
|
||||||
|
|
||||||
def answer_dir(self, job_id: str) -> Path:
|
def answer_dir(self, job_id: str) -> Path:
|
||||||
return self.root / self.settings.SCAN_OUTPUT_DIR / job_id
|
return self.root / self.settings.SCAN_OUTPUT_DIR / job_id
|
||||||
|
|
||||||
@ -260,12 +256,6 @@ class Roundtrip:
|
|||||||
def inbox(self, job_id: str) -> list[str]:
|
def inbox(self, job_id: str) -> list[str]:
|
||||||
return sorted(p.name for p in self.input_dir.iterdir() if p.name.startswith(job_id))
|
return sorted(p.name for p in self.input_dir.iterdir() if p.name.startswith(job_id))
|
||||||
|
|
||||||
def stray_parts(self) -> list[str]:
|
|
||||||
"""Временные `.part` записи во входящий каталог: после успешной публикации их в `Tmp` нет (решение №26)."""
|
|
||||||
return (
|
|
||||||
sorted(p.name for p in self.tmp_dir.iterdir() if p.name.endswith(".part")) if self.tmp_dir.is_dir() else []
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(self, original_name: str, data: bytes, *, cleanup: bool = False) -> tuple[dict[str, Any], Envelope]:
|
async def run(self, original_name: str, data: bytes, *, cleanup: bool = False) -> tuple[dict[str, Any], Envelope]:
|
||||||
"""Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог."""
|
"""Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог."""
|
||||||
body = await self.request(original_name, data)
|
body = await self.request(original_name, data)
|
||||||
@ -283,8 +273,7 @@ class Roundtrip:
|
|||||||
"""Сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий файл `<job_id>.pdf` = объект S3.
|
"""Сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий файл `<job_id>.pdf` = объект S3.
|
||||||
|
|
||||||
Без очистки (`SCAN_CLEANUP=false`) каталог ответа и входящий файл (с суффиксом `.done` эмулятора) остаются; при
|
Без очистки (`SCAN_CLEANUP=false`) каталог ответа и входящий файл (с суффиксом `.done` эмулятора) остаются; при
|
||||||
очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output/<job_id>/` нет. `.part` в `Tmp` нет
|
очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output/<job_id>/` нет.
|
||||||
в обоих режимах.
|
|
||||||
"""
|
"""
|
||||||
job_id = body["job_id"]
|
job_id = body["job_id"]
|
||||||
assert result.body["file_id"] == body["file_id"]
|
assert result.body["file_id"] == body["file_id"]
|
||||||
@ -297,7 +286,6 @@ class Roundtrip:
|
|||||||
assert entry["response"]["file_uid"] == job_id
|
assert entry["response"]["file_uid"] == job_id
|
||||||
stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
|
stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
|
||||||
assert stat.content_type == JSON_CONTENT_TYPE
|
assert stat.content_type == JSON_CONTENT_TYPE
|
||||||
assert self.stray_parts() == []
|
|
||||||
if cleanup:
|
if cleanup:
|
||||||
assert list(self.input_dir.iterdir()) == []
|
assert list(self.input_dir.iterdir()) == []
|
||||||
assert not self.answer_dir(job_id).exists()
|
assert not self.answer_dir(job_id).exists()
|
||||||
@ -393,7 +381,7 @@ def _start_emulator(stack: AsyncExitStack, settings: Settings, root: Path, fixtu
|
|||||||
hotfolder_path=root,
|
hotfolder_path=root,
|
||||||
input_dir=settings.SCAN_INPUT_DIR,
|
input_dir=settings.SCAN_INPUT_DIR,
|
||||||
output_dir=settings.SCAN_OUTPUT_DIR,
|
output_dir=settings.SCAN_OUTPUT_DIR,
|
||||||
tmp_dir=settings.SCAN_TMP_DIR,
|
tmp_dir="Tmp", # служебный каталог эмулятора; у шлюза его нет (change hotfolder-direct-write)
|
||||||
fixtures_dir=fixtures,
|
fixtures_dir=fixtures,
|
||||||
delay=EMULATOR_DELAY,
|
delay=EMULATOR_DELAY,
|
||||||
poll_interval=0.05,
|
poll_interval=0.05,
|
||||||
@ -650,4 +638,3 @@ async def test_cleanup_enabled_leaves_input_and_answer_empty(roundtrip_cleanup:
|
|||||||
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json"]
|
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json"]
|
||||||
assert list(roundtrip_cleanup.input_dir.iterdir()) == []
|
assert list(roundtrip_cleanup.input_dir.iterdir()) == []
|
||||||
assert not roundtrip_cleanup.answer_dir(job_id).exists()
|
assert not roundtrip_cleanup.answer_dir(job_id).exists()
|
||||||
assert roundtrip_cleanup.stray_parts() == []
|
|
||||||
|
|||||||
@ -58,7 +58,6 @@ async def make_env(tmp_path: Path) -> AsyncIterator[EnvFactory]:
|
|||||||
root,
|
root,
|
||||||
settings.SCAN_INPUT_DIR,
|
settings.SCAN_INPUT_DIR,
|
||||||
settings.SCAN_OUTPUT_DIR,
|
settings.SCAN_OUTPUT_DIR,
|
||||||
settings.SCAN_TMP_DIR,
|
|
||||||
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
||||||
cleanup=settings.SCAN_CLEANUP,
|
cleanup=settings.SCAN_CLEANUP,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -29,10 +29,10 @@ def test_defaults_local_mode_without_smb_and_nats_credentials(monkeypatch, minim
|
|||||||
assert s.SCAN_HOTFOLDER_MODE == "local"
|
assert s.SCAN_HOTFOLDER_MODE == "local"
|
||||||
assert s.SCAN_INPUT_DIR == "Input"
|
assert s.SCAN_INPUT_DIR == "Input"
|
||||||
assert s.SCAN_OUTPUT_DIR == "Output"
|
assert s.SCAN_OUTPUT_DIR == "Output"
|
||||||
assert s.SCAN_TMP_DIR == "Tmp"
|
assert not hasattr(s, "SCAN_TMP_DIR") # служебный каталог только у эмулятора (change hotfolder-direct-write)
|
||||||
assert s.SCAN_SETTLE_SECONDS == 2
|
assert s.SCAN_SETTLE_SECONDS == 2
|
||||||
assert s.SCAN_POLL_SECONDS == 5
|
assert s.SCAN_POLL_SECONDS == 5
|
||||||
assert s.SCAN_TIMEOUT_SECONDS == 300
|
assert s.SCAN_TIMEOUT_SECONDS == 1200
|
||||||
assert s.SCAN_MAX_INFLIGHT == 4
|
assert s.SCAN_MAX_INFLIGHT == 4
|
||||||
assert s.OPENBAO.SMB.SMB_HOST == ""
|
assert s.OPENBAO.SMB.SMB_HOST == ""
|
||||||
assert s.OPENBAO.NATS.NATS_SEED == ""
|
assert s.OPENBAO.NATS.NATS_SEED == ""
|
||||||
@ -80,6 +80,16 @@ def test_negative_settle_seconds_rejected(monkeypatch):
|
|||||||
get_settings()
|
get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_scan_tmp_dir_is_ignored(monkeypatch, minimal_env_file):
|
||||||
|
"""Старый `.env` с `SCAN_TMP_DIR` (до change hotfolder-direct-write) не ломает старт: `extra="ignore"`."""
|
||||||
|
_clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("SCAN_TMP_DIR", "Tmp")
|
||||||
|
|
||||||
|
s = get_settings()
|
||||||
|
|
||||||
|
assert not hasattr(s, "SCAN_TMP_DIR")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("settle", ["10", "11"])
|
@pytest.mark.parametrize("settle", ["10", "11"])
|
||||||
def test_settle_not_less_than_timeout_rejected(monkeypatch, settle):
|
def test_settle_not_less_than_timeout_rejected(monkeypatch, settle):
|
||||||
"""M-4: набор не может настояться за срок, который короче или равен settle — каждое задание давало бы TIMEOUT."""
|
"""M-4: набор не может настояться за срок, который короче или равен settle — каждое задание давало бы TIMEOUT."""
|
||||||
@ -156,7 +166,7 @@ def test_env_test_file_loads_for_tests():
|
|||||||
assert s.SCAN_HOTFOLDER_PATH == "./.hotfolder-test"
|
assert s.SCAN_HOTFOLDER_PATH == "./.hotfolder-test"
|
||||||
assert s.SCAN_POLL_SECONDS == 0.1
|
assert s.SCAN_POLL_SECONDS == 0.1
|
||||||
assert s.SCAN_TIMEOUT_SECONDS == 5
|
assert s.SCAN_TIMEOUT_SECONDS == 5
|
||||||
assert s.SCAN_TMP_DIR == "Tmp"
|
assert not hasattr(s, "SCAN_TMP_DIR")
|
||||||
assert s.SCAN_SETTLE_SECONDS == 0 # R-23: тесты воркера не ждут стабилизации набора
|
assert s.SCAN_SETTLE_SECONDS == 0 # R-23: тесты воркера не ждут стабилизации набора
|
||||||
assert s.OPENBAO.MINIO.MINIO_URL == "127.0.0.1:9000"
|
assert s.OPENBAO.MINIO.MINIO_URL == "127.0.0.1:9000"
|
||||||
assert s.OPENBAO.MINIO.MINIO_SECURE is False
|
assert s.OPENBAO.MINIO.MINIO_SECURE is False
|
||||||
@ -172,7 +182,7 @@ def test_env_example_loads(monkeypatch):
|
|||||||
|
|
||||||
assert s.SCAN_HOTFOLDER_MODE == "local"
|
assert s.SCAN_HOTFOLDER_MODE == "local"
|
||||||
assert s.SCAN_INPUT_DIR == "Input"
|
assert s.SCAN_INPUT_DIR == "Input"
|
||||||
assert s.SCAN_TMP_DIR == "Tmp"
|
assert not hasattr(s, "SCAN_TMP_DIR")
|
||||||
assert s.SCAN_SETTLE_SECONDS == 2
|
assert s.SCAN_SETTLE_SECONDS == 2
|
||||||
assert s.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka"
|
assert s.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka"
|
||||||
assert s.OPENBAO.NATS.NATS_JWT == ""
|
assert s.OPENBAO.NATS.NATS_JWT == ""
|
||||||
|
|||||||
@ -20,7 +20,6 @@ def _local_settings(root: Path) -> Settings:
|
|||||||
"SCAN_HOTFOLDER_MODE": "local",
|
"SCAN_HOTFOLDER_MODE": "local",
|
||||||
"SCAN_HOTFOLDER_PATH": str(root),
|
"SCAN_HOTFOLDER_PATH": str(root),
|
||||||
"SCAN_INPUT_DIR": "In",
|
"SCAN_INPUT_DIR": "In",
|
||||||
"SCAN_TMP_DIR": "Служебный",
|
|
||||||
"SCAN_SETTLE_SECONDS": 1.5,
|
"SCAN_SETTLE_SECONDS": 1.5,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -33,7 +32,6 @@ def _smb_settings() -> Settings:
|
|||||||
update={
|
update={
|
||||||
"SCAN_HOTFOLDER_MODE": "smb",
|
"SCAN_HOTFOLDER_MODE": "smb",
|
||||||
"SCAN_OUTPUT_DIR": "Out",
|
"SCAN_OUTPUT_DIR": "Out",
|
||||||
"SCAN_TMP_DIR": "T",
|
|
||||||
"SCAN_SETTLE_SECONDS": 3,
|
"SCAN_SETTLE_SECONDS": 3,
|
||||||
"OPENBAO": settings.OPENBAO.model_copy(update={"SMB": smb}),
|
"OPENBAO": settings.OPENBAO.model_copy(update={"SMB": smb}),
|
||||||
}
|
}
|
||||||
@ -46,7 +44,7 @@ def test_local_mode_builds_local_hotfolder(tmp_path: Path) -> None:
|
|||||||
assert hf.root == tmp_path
|
assert hf.root == tmp_path
|
||||||
assert hf.input_dir == tmp_path / "In"
|
assert hf.input_dir == tmp_path / "In"
|
||||||
assert hf.output_dir == tmp_path / "Output"
|
assert hf.output_dir == tmp_path / "Output"
|
||||||
assert hf.tmp_dir == tmp_path / "Служебный"
|
assert hf.tmp_dir == tmp_path / "Tmp" # служебный каталог эмулятора, шлюз его не использует
|
||||||
assert hf.settle_seconds == 1.5
|
assert hf.settle_seconds == 1.5
|
||||||
assert hf.healthy() is True
|
assert hf.healthy() is True
|
||||||
|
|
||||||
@ -66,7 +64,7 @@ def test_smb_mode_builds_smb_hotfolder() -> None:
|
|||||||
assert hf.root == "\\\\smb.example.local\\share\\scan"
|
assert hf.root == "\\\\smb.example.local\\share\\scan"
|
||||||
assert hf.input_dir == "\\\\smb.example.local\\share\\scan\\Input"
|
assert hf.input_dir == "\\\\smb.example.local\\share\\scan\\Input"
|
||||||
assert hf.output_dir == "\\\\smb.example.local\\share\\scan\\Out"
|
assert hf.output_dir == "\\\\smb.example.local\\share\\scan\\Out"
|
||||||
assert hf.tmp_dir == "\\\\smb.example.local\\share\\scan\\T"
|
assert not hasattr(hf, "tmp_dir") # у шлюза служебного каталога нет (change hotfolder-direct-write)
|
||||||
assert hf.settle_seconds == 3
|
assert hf.settle_seconds == 3
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton).
|
"""Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton).
|
||||||
|
|
||||||
Быстрые тесты на `tmp_path`: атомарная запись входящего файла через служебный каталог `Tmp`, опрос набора ответов ИС
|
Быстрые тесты на `tmp_path`: запись входящего файла прямо в `Input` с перезаписью, опрос набора ответов ИС
|
||||||
с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён.
|
с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from src.gateway import hotfolder as hotfolder_module
|
||||||
from src.gateway.hotfolder import AnswerFile, HotFolder, LocalHotFolder, SettleTracker, inbox_name
|
from src.gateway.hotfolder import AnswerFile, HotFolder, LocalHotFolder, SettleTracker, inbox_name
|
||||||
|
|
||||||
|
|
||||||
@ -41,6 +42,31 @@ def _settled(tmp_path: Path, settle: float = 2) -> tuple[LocalHotFolder, FakeClo
|
|||||||
return LocalHotFolder(tmp_path, settle_seconds=settle, clock=clock), clock
|
return LocalHotFolder(tmp_path, settle_seconds=settle, clock=clock), clock
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingFile:
|
||||||
|
"""Файл, открытый на запись: `write` пишет первые два байта и падает (диск полон)."""
|
||||||
|
|
||||||
|
def __init__(self, path: Any, mode: str) -> None:
|
||||||
|
self._handle = open(path, mode) # noqa: SIM115 — закрывается в __exit__
|
||||||
|
|
||||||
|
def __enter__(self) -> "_FailingFile":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc: object) -> None:
|
||||||
|
self._handle.close()
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> int:
|
||||||
|
self._handle.write(data[:2])
|
||||||
|
raise OSError(errno.ENOSPC, "диск полон")
|
||||||
|
|
||||||
|
|
||||||
|
def _failing_open(path: Any, mode: str = "r", *args: Any, **kwargs: Any) -> _FailingFile:
|
||||||
|
return _FailingFile(path, mode)
|
||||||
|
|
||||||
|
|
||||||
|
def _failing_unlink(self: Path, missing_ok: bool = False) -> None:
|
||||||
|
raise PermissionError(errno.EACCES, "нет прав")
|
||||||
|
|
||||||
|
|
||||||
class TestInboxName:
|
class TestInboxName:
|
||||||
def test_job_id_and_lowercase_extension(self) -> None:
|
def test_job_id_and_lowercase_extension(self) -> None:
|
||||||
job_id = _job_id()
|
job_id = _job_id()
|
||||||
@ -57,34 +83,12 @@ class TestInboxName:
|
|||||||
inbox_name("job", original)
|
inbox_name("job", original)
|
||||||
|
|
||||||
|
|
||||||
class TestPutWithoutReplace:
|
|
||||||
def test_existing_destination_is_kept(self, tmp_path: Path) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
hf.put("job.pdf", b"first")
|
|
||||||
hf.put("job.pdf", b"second")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
|
|
||||||
# R-17: назначение есть — повтор ничего не пишет; прежнего `.part` после успешной публикации нет
|
|
||||||
assert not (hf.tmp_dir / ".job.pdf.part").exists()
|
|
||||||
|
|
||||||
def test_existing_destination_part_removed_when_cleanup_enabled(self, tmp_path: Path) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
hf.put("job.pdf", b"first")
|
|
||||||
hf.put("job.pdf", b"second")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
|
|
||||||
assert not (hf.tmp_dir / ".job.pdf.part").exists()
|
|
||||||
|
|
||||||
def test_no_temp_files_in_input(self, tmp_path: Path) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
hf.put("job.pdf", b"x")
|
|
||||||
assert [p.name for p in hf.input_dir.iterdir()] == ["job.pdf"]
|
|
||||||
assert not (hf.tmp_dir / ".job.pdf.part").exists() # после успешной публикации .part нет
|
|
||||||
|
|
||||||
|
|
||||||
class TestCleanup:
|
class TestCleanup:
|
||||||
def _fill(self, hf: LocalHotFolder, job_id: str) -> None:
|
def _fill(self, hf: LocalHotFolder, job_id: str) -> None:
|
||||||
hf.put(f"{job_id}.pdf", b"x")
|
hf.put(f"{job_id}.pdf", b"x")
|
||||||
(hf.input_dir / f"{job_id}.pdf.done").write_bytes(b"")
|
(hf.input_dir / f"{job_id}.pdf.done").write_bytes(b"")
|
||||||
(hf.tmp_dir / f".{job_id}.pdf.part").write_bytes(b"orphan")
|
hf.tmp_dir.mkdir(exist_ok=True)
|
||||||
|
(hf.tmp_dir / f".{job_id}.pdf.part").write_bytes(b"legacy") # остаток прежних версий шлюза — не наш
|
||||||
_write_answer(hf, job_id, b"{}")
|
_write_answer(hf, job_id, b"{}")
|
||||||
_write_answer(hf, job_id, b"{}", name=f"{job_id}_ofr.json")
|
_write_answer(hf, job_id, b"{}", name=f"{job_id}_ofr.json")
|
||||||
(hf.answer_dir(job_id) / "readme.txt").write_bytes(b"keep")
|
(hf.answer_dir(job_id) / "readme.txt").write_bytes(b"keep")
|
||||||
@ -104,7 +108,7 @@ class TestCleanup:
|
|||||||
assert hf.cleanup(job_id, f"{job_id}.pdf") is True
|
assert hf.cleanup(job_id, f"{job_id}.pdf") is True
|
||||||
assert not (hf.input_dir / f"{job_id}.pdf").exists()
|
assert not (hf.input_dir / f"{job_id}.pdf").exists()
|
||||||
assert not (hf.input_dir / f"{job_id}.pdf.done").exists()
|
assert not (hf.input_dir / f"{job_id}.pdf.done").exists()
|
||||||
assert not (hf.tmp_dir / f".{job_id}.pdf.part").exists()
|
assert (hf.tmp_dir / f".{job_id}.pdf.part").exists() # Tmp очистка не трогает
|
||||||
assert not hf.answer_dir(job_id).exists() # каталог удалён вместе с readme.txt
|
assert not hf.answer_dir(job_id).exists() # каталог удалён вместе с readme.txt
|
||||||
assert hf.cleanup(job_id, f"{job_id}.pdf") is True # повтор — не ошибка
|
assert hf.cleanup(job_id, f"{job_id}.pdf") is True # повтор — не ошибка
|
||||||
|
|
||||||
@ -181,60 +185,57 @@ class TestPut:
|
|||||||
hf.put(name, data)
|
hf.put(name, data)
|
||||||
assert (tmp_path / "Input" / name).read_bytes() == data
|
assert (tmp_path / "Input" / name).read_bytes() == data
|
||||||
|
|
||||||
def test_leaves_no_part_files(self, tmp_path: Path) -> None:
|
def test_creates_input_dir_only(self, tmp_path: Path) -> None:
|
||||||
hf = LocalHotFolder(tmp_path)
|
hf = LocalHotFolder(tmp_path)
|
||||||
job_id = _job_id()
|
|
||||||
name = inbox_name(job_id, "aep_5.pdf")
|
|
||||||
hf.put(name, b"data")
|
|
||||||
assert [p.name for p in hf.input_dir.iterdir()] == [name]
|
|
||||||
assert list(hf.tmp_dir.iterdir()) == []
|
|
||||||
|
|
||||||
def test_part_is_written_in_tmp_not_in_input(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""OQ-79: во время записи во входящем каталоге нет временного файла — он в `Tmp/`, затем link в `Input`."""
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
job_id = _job_id()
|
|
||||||
name = inbox_name(job_id, "aep_5.pdf")
|
|
||||||
seen: dict[str, Any] = {}
|
|
||||||
real_link = os.link
|
|
||||||
|
|
||||||
def spy(src: Any, dst: Any) -> None:
|
|
||||||
seen["src"], seen["dst"] = Path(src), Path(dst)
|
|
||||||
seen["input"] = sorted(p.name for p in hf.input_dir.iterdir())
|
|
||||||
seen["tmp"] = sorted(p.name for p in hf.tmp_dir.iterdir())
|
|
||||||
seen["part"] = Path(src).read_bytes()
|
|
||||||
real_link(src, dst)
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", spy)
|
|
||||||
hf.put(name, b"data")
|
|
||||||
assert seen == {
|
|
||||||
"src": hf.tmp_dir / f".{name}.part",
|
|
||||||
"dst": hf.input_dir / name,
|
|
||||||
"input": [],
|
|
||||||
"tmp": [f".{name}.part"],
|
|
||||||
"part": b"data",
|
|
||||||
}
|
|
||||||
assert [p.name for p in hf.input_dir.iterdir()] == [name]
|
|
||||||
assert list(hf.tmp_dir.iterdir()) == []
|
|
||||||
|
|
||||||
def test_creates_input_and_tmp_dirs_only(self, tmp_path: Path) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
assert not hf.input_dir.exists()
|
|
||||||
assert not hf.tmp_dir.exists()
|
|
||||||
hf.put("a.pdf", b"1")
|
hf.put("a.pdf", b"1")
|
||||||
assert hf.input_dir.is_dir()
|
assert sorted(p.name for p in tmp_path.iterdir()) == ["Input"] # Tmp не создаётся
|
||||||
assert hf.tmp_dir.is_dir()
|
|
||||||
assert not hf.output_dir.exists()
|
|
||||||
|
|
||||||
def test_existing_tmp_dir_is_fine(self, tmp_path: Path) -> None:
|
def test_existing_file_is_overwritten(self, tmp_path: Path) -> None:
|
||||||
(tmp_path / "Tmp").mkdir()
|
"""Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл."""
|
||||||
LocalHotFolder(tmp_path).put("a.pdf", b"1")
|
hf = LocalHotFolder(tmp_path, settle_seconds=0)
|
||||||
assert (tmp_path / "Input" / "a.pdf").read_bytes() == b"1"
|
hf.put("job.pdf", b"first, longer")
|
||||||
|
hf.put("job.pdf", b"second")
|
||||||
|
assert (hf.input_dir / "job.pdf").read_bytes() == b"second"
|
||||||
|
|
||||||
def test_custom_tmp_dir_name(self, tmp_path: Path) -> None:
|
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
|
||||||
hf = LocalHotFolder(tmp_path, tmp_dir="Служебный")
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
hf.put("a.pdf", b"1")
|
) -> None:
|
||||||
assert (tmp_path / "Служебный").is_dir()
|
hf = LocalHotFolder(tmp_path, settle_seconds=0)
|
||||||
assert not (tmp_path / "Tmp").exists()
|
monkeypatch.setattr(hotfolder_module, "open", _failing_open, raising=False)
|
||||||
|
with pytest.raises(OSError, match="диск полон"):
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
assert (hf.input_dir / "job.pdf").read_bytes() == b"da"
|
||||||
|
|
||||||
|
def test_write_failure_removes_partial_file_when_cleanup_enabled(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
|
||||||
|
monkeypatch.setattr(hotfolder_module, "open", _failing_open, raising=False)
|
||||||
|
with pytest.raises(OSError, match="диск полон"):
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
assert not (hf.input_dir / "job.pdf").exists()
|
||||||
|
|
||||||
|
def test_remove_failure_does_not_mask_write_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
|
||||||
|
monkeypatch.setattr(hotfolder_module, "open", _failing_open, raising=False)
|
||||||
|
monkeypatch.setattr(Path, "unlink", _failing_unlink)
|
||||||
|
with pytest.raises(OSError, match="диск полон"):
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cleanup", [False, True])
|
||||||
|
def test_open_failure_never_removes_existing_file(self, tmp_path: Path, cleanup: bool) -> None:
|
||||||
|
"""I-1: открыть не удалось — недописанного файла нет, целый файл прошлой доставки не трогаем."""
|
||||||
|
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=cleanup)
|
||||||
|
hf.input_dir.mkdir()
|
||||||
|
delivered = hf.input_dir / "job.pdf"
|
||||||
|
delivered.write_bytes(b"delivered")
|
||||||
|
delivered.chmod(0o444)
|
||||||
|
try:
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
hf.put("job.pdf", b"new")
|
||||||
|
assert delivered.read_bytes() == b"delivered"
|
||||||
|
finally:
|
||||||
|
delivered.chmod(0o644)
|
||||||
|
|
||||||
def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None:
|
def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None:
|
||||||
(tmp_path / "Input").mkdir()
|
(tmp_path / "Input").mkdir()
|
||||||
@ -285,227 +286,6 @@ class TestPut:
|
|||||||
hf.put(name, b"x")
|
hf.put(name, b"x")
|
||||||
assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names)
|
assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names)
|
||||||
|
|
||||||
def test_cleans_part_file_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""При cleanup=True и ошибке link: .part удаляется."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
|
|
||||||
def boom(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError("диск недоступен")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
|
|
||||||
with pytest.raises(OSError, match="диск недоступен"):
|
|
||||||
hf.put("a.pdf", b"data")
|
|
||||||
assert list(hf.input_dir.iterdir()) == []
|
|
||||||
assert list(hf.tmp_dir.iterdir()) == []
|
|
||||||
|
|
||||||
def test_keeps_part_file_on_failure_when_cleanup_disabled(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""При cleanup=False и ошибке link: .part остаётся в Tmp (T11.1)."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=False)
|
|
||||||
|
|
||||||
def boom(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError("диск недоступен")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
|
|
||||||
with pytest.raises(OSError, match="диск недоступен"):
|
|
||||||
hf.put("a.pdf", b"data")
|
|
||||||
assert list(hf.input_dir.iterdir()) == []
|
|
||||||
assert [p.name for p in hf.tmp_dir.iterdir()] == [".a.pdf.part"]
|
|
||||||
|
|
||||||
def test_successful_put_despite_final_unlink_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Успешный link, но ошибка в завершающем unlink .part (R-8): put НЕ падает, файл доставлен (T11.2)."""
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
|
|
||||||
unlink_calls = []
|
|
||||||
|
|
||||||
real_unlink = os.unlink
|
|
||||||
|
|
||||||
def unlink_with_failure(path: Any) -> None:
|
|
||||||
unlink_calls.append(path)
|
|
||||||
if str(path).endswith(".part"):
|
|
||||||
raise PermissionError("удаление запрещено")
|
|
||||||
real_unlink(path)
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.unlink", unlink_with_failure)
|
|
||||||
hf.put("a.pdf", b"data") # НЕ падает несмотря на ошибку unlink
|
|
||||||
assert (hf.input_dir / "a.pdf").read_bytes() == b"data"
|
|
||||||
assert (hf.tmp_dir / ".a.pdf.part").exists() # .part не удалён из-за ошибки
|
|
||||||
assert unlink_calls # попытка удаления была
|
|
||||||
|
|
||||||
def test_cleanup_failure_does_not_mask_original_error(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Ошибка удаления `.part` на пути ошибки link подавляется (T11.1); исходное исключение проходит."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
|
|
||||||
def boom(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError("диск недоступен")
|
|
||||||
|
|
||||||
def unlink_fails(self: Path, missing_ok: bool = False) -> None:
|
|
||||||
raise PermissionError("удаление .part запрещено")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
|
|
||||||
monkeypatch.setattr(Path, "unlink", unlink_fails)
|
|
||||||
with pytest.raises(OSError, match="диск недоступен"):
|
|
||||||
hf.put("a.pdf", b"data")
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartIsCreatedFresh:
|
|
||||||
"""Решение №32: `.part` создаётся заново (удаление старого + `O_EXCL`), fallback — только вокруг `os.link`."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("cleanup", [False, True])
|
|
||||||
def test_existing_destination_is_not_touched_by_repeat(self, tmp_path: Path, cleanup: bool) -> None:
|
|
||||||
"""R-17: назначение есть, `.part` — жёсткая ссылка на него: байты не меняются, put не падает."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=cleanup)
|
|
||||||
hf.put("job.pdf", b"first")
|
|
||||||
published = hf.input_dir / "job.pdf"
|
|
||||||
stale = hf.tmp_dir / ".job.pdf.part"
|
|
||||||
os.link(published, stale)
|
|
||||||
hf.put("job.pdf", b"different data")
|
|
||||||
assert published.read_bytes() == b"first"
|
|
||||||
assert stale.exists() is (not cleanup) # очистка снимает `.part`, иначе он остаётся как был
|
|
||||||
if not cleanup:
|
|
||||||
assert stale.read_bytes() == b"first" and published.stat().st_nlink == 2
|
|
||||||
|
|
||||||
def test_existing_destination_with_undeletable_part_does_not_fail(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path) # очистка выключена: удаление `.part` вообще не пытается
|
|
||||||
hf.put("job.pdf", b"first")
|
|
||||||
os.link(hf.input_dir / "job.pdf", hf.tmp_dir / ".job.pdf.part")
|
|
||||||
|
|
||||||
def deny(self: Path, missing_ok: bool = False) -> None:
|
|
||||||
raise PermissionError("удаление запрещено")
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "unlink", deny)
|
|
||||||
hf.put("job.pdf", b"second")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
|
|
||||||
|
|
||||||
def test_undeletable_stale_part_with_single_link_is_truncated(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Назначения нет, старый `.part` не удаляется, но это обычный файл (nlink == 1): put успешен."""
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
hf.tmp_dir.mkdir()
|
|
||||||
stale = hf.tmp_dir / ".job.pdf.part"
|
|
||||||
stale.write_bytes(b"old and longer content")
|
|
||||||
|
|
||||||
def deny(self: Path, missing_ok: bool = False) -> None:
|
|
||||||
raise PermissionError("удаление запрещено")
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "unlink", deny)
|
|
||||||
hf.put("job.pdf", b"new")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"new"
|
|
||||||
|
|
||||||
def test_undeletable_stale_part_with_other_links_is_not_written(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Назначения нет, старый `.part` не удаляется и имеет nlink == 2 (синтетически): запись не идёт, ошибка не
|
|
||||||
маскируется, второй файл не изменён."""
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
hf.tmp_dir.mkdir()
|
|
||||||
other = tmp_path / "other.bin"
|
|
||||||
other.write_bytes(b"precious")
|
|
||||||
os.link(other, hf.tmp_dir / ".job.pdf.part")
|
|
||||||
|
|
||||||
def deny(self: Path, missing_ok: bool = False) -> None:
|
|
||||||
raise PermissionError("удаление запрещено")
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "unlink", deny)
|
|
||||||
with pytest.raises(FileExistsError):
|
|
||||||
hf.put("job.pdf", b"new")
|
|
||||||
assert other.read_bytes() == b"precious"
|
|
||||||
assert not (hf.input_dir / "job.pdf").exists()
|
|
||||||
|
|
||||||
def test_stale_part_is_replaced_before_publication(self, tmp_path: Path) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
hf.tmp_dir.mkdir()
|
|
||||||
(hf.tmp_dir / ".job.pdf.part").write_bytes(b"old and longer content")
|
|
||||||
hf.put("job.pdf", b"new")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"new"
|
|
||||||
assert not (hf.tmp_dir / ".job.pdf.part").exists()
|
|
||||||
|
|
||||||
def test_part_is_opened_exclusively(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
flags: list[int] = []
|
|
||||||
real_open = os.open
|
|
||||||
|
|
||||||
def spy(path: Any, flag: int, mode: int = 0o777, **kwargs: Any) -> int:
|
|
||||||
flags.append(flag)
|
|
||||||
return real_open(path, flag, mode, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.open", spy)
|
|
||||||
hf.put("job.pdf", b"x")
|
|
||||||
assert flags and flags[0] & os.O_EXCL and flags[0] & os.O_CREAT
|
|
||||||
|
|
||||||
def test_write_error_propagates_as_itself(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""EPERM записи не превращается в `FileNotFoundError` от `os.replace` несуществующего `.part`."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
|
|
||||||
class FailingFile:
|
|
||||||
def __enter__(self) -> "FailingFile":
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *exc: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def write(self, data: bytes) -> int:
|
|
||||||
raise OSError(errno.EPERM, "запись запрещена")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.fdopen", lambda fd, mode: (os.close(fd), FailingFile())[1])
|
|
||||||
with pytest.raises(OSError, match="запись запрещена") as caught:
|
|
||||||
hf.put("job.pdf", b"data")
|
|
||||||
assert caught.value.errno == errno.EPERM and not isinstance(caught.value, FileNotFoundError)
|
|
||||||
assert list(hf.input_dir.iterdir()) == [] and list(hf.tmp_dir.iterdir()) == []
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("code", [errno.EPERM, errno.EXDEV, errno.ENOTSUP, errno.EOPNOTSUPP])
|
|
||||||
def test_fallback_to_replace_when_link_unsupported(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, code: int
|
|
||||||
) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
|
|
||||||
def no_links(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError(code, "жёсткие ссылки не поддерживаются")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
|
|
||||||
hf.put("job.pdf", b"data")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"data"
|
|
||||||
assert list(hf.tmp_dir.iterdir()) == [] # `.part` стал назначением
|
|
||||||
|
|
||||||
def test_no_fallback_when_destination_appeared_during_link(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Назначение появилось между проверкой и линковкой, а `os.link` дал EPERM: `os.replace` не затирает."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
hf.input_dir.mkdir()
|
|
||||||
|
|
||||||
def no_links(src: Any, dst: Any) -> None:
|
|
||||||
Path(dst).write_bytes(b"first") # файл успел появиться (параллельный повтор того же задания)
|
|
||||||
raise OSError(errno.EPERM, "жёсткие ссылки не поддерживаются")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
|
|
||||||
with pytest.raises(OSError, match="жёсткие ссылки"):
|
|
||||||
hf.put("job.pdf", b"second")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
|
|
||||||
|
|
||||||
def test_replace_failure_after_link_fallback_propagates(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
|
|
||||||
def no_links(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError(errno.EPERM, "жёсткие ссылки не поддерживаются")
|
|
||||||
|
|
||||||
def broken_replace(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError(errno.EIO, "замена не удалась")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.replace", broken_replace)
|
|
||||||
with pytest.raises(OSError, match="замена не удалась"):
|
|
||||||
hf.put("job.pdf", b"data")
|
|
||||||
assert list(hf.tmp_dir.iterdir()) == [] # при очистке `.part` снят
|
|
||||||
|
|
||||||
|
|
||||||
class TestPoll:
|
class TestPoll:
|
||||||
"""`poll` — все `*.json` каталога `<output>/<job_id>/` по имени, когда набор настоялся `settle_seconds`."""
|
"""`poll` — все `*.json` каталога `<output>/<job_id>/` по имени, когда набор настоялся `settle_seconds`."""
|
||||||
|
|||||||
@ -21,7 +21,6 @@ HOST = "smb.example.local"
|
|||||||
ROOT = "\\\\smb.example.local\\share\\scan"
|
ROOT = "\\\\smb.example.local\\share\\scan"
|
||||||
INPUT = ROOT + "\\Input"
|
INPUT = ROOT + "\\Input"
|
||||||
OUTPUT = ROOT + "\\Output"
|
OUTPUT = ROOT + "\\Output"
|
||||||
TMP = ROOT + "\\Tmp"
|
|
||||||
|
|
||||||
|
|
||||||
class FakeClock:
|
class FakeClock:
|
||||||
@ -171,6 +170,26 @@ def _write_answer(share_root: Path, job_id: str, data: bytes, name: str | None =
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _fail_write(fake: FakeSmbClient) -> SMBOSError:
|
||||||
|
"""`open_file` открывает файл, но `write` пишет два байта и падает (диск полон): недописанный файл остаётся."""
|
||||||
|
error = SMBOSError(NtStatus.STATUS_DISK_FULL, INPUT)
|
||||||
|
real_open = fake.open_file
|
||||||
|
|
||||||
|
def open_file(path: str, mode: str = "r", **kwargs: Any) -> IO[Any]:
|
||||||
|
handle = real_open(path, mode, **kwargs)
|
||||||
|
|
||||||
|
def write(data: bytes) -> int:
|
||||||
|
handle.raw.write(data[:2]) if hasattr(handle, "raw") else None
|
||||||
|
handle.flush()
|
||||||
|
raise error
|
||||||
|
|
||||||
|
handle.write = write # type: ignore[method-assign]
|
||||||
|
return handle
|
||||||
|
|
||||||
|
fake.open_file = open_file # type: ignore[method-assign]
|
||||||
|
return error
|
||||||
|
|
||||||
|
|
||||||
class TestPaths:
|
class TestPaths:
|
||||||
def test_default_layout(self) -> None:
|
def test_default_layout(self) -> None:
|
||||||
hf = _hotfolder()
|
hf = _hotfolder()
|
||||||
@ -178,7 +197,6 @@ class TestPaths:
|
|||||||
assert hf.root == ROOT
|
assert hf.root == ROOT
|
||||||
assert hf.input_dir == INPUT
|
assert hf.input_dir == INPUT
|
||||||
assert hf.output_dir == OUTPUT
|
assert hf.output_dir == OUTPUT
|
||||||
assert hf.tmp_dir == TMP
|
|
||||||
assert hf.answer_dir(job_id) == f"{OUTPUT}\\{job_id}"
|
assert hf.answer_dir(job_id) == f"{OUTPUT}\\{job_id}"
|
||||||
assert hf.answer_path(job_id) == f"{OUTPUT}\\{job_id}\\{job_id}.json"
|
assert hf.answer_path(job_id) == f"{OUTPUT}\\{job_id}\\{job_id}.json"
|
||||||
|
|
||||||
@ -194,10 +212,9 @@ class TestPaths:
|
|||||||
assert SmbHotFolder(HOST, "share").root == "\\\\smb.example.local\\share"
|
assert SmbHotFolder(HOST, "share").root == "\\\\smb.example.local\\share"
|
||||||
|
|
||||||
def test_custom_dir_names(self) -> None:
|
def test_custom_dir_names(self) -> None:
|
||||||
hf = SmbHotFolder(HOST, "share/scan", input_dir="Входящие", output_dir="Исходящие", tmp_dir="Служебный")
|
hf = SmbHotFolder(HOST, "share/scan", input_dir="Входящие", output_dir="Исходящие")
|
||||||
assert hf.input_dir == ROOT + "\\Входящие"
|
assert hf.input_dir == ROOT + "\\Входящие"
|
||||||
assert hf.output_dir == ROOT + "\\Исходящие"
|
assert hf.output_dir == ROOT + "\\Исходящие"
|
||||||
assert hf.tmp_dir == ROOT + "\\Служебный"
|
|
||||||
assert hf.answer_path("abc") == ROOT + "\\Исходящие\\abc\\abc.json"
|
assert hf.answer_path("abc") == ROOT + "\\Исходящие\\abc\\abc.json"
|
||||||
|
|
||||||
@pytest.mark.parametrize(("host", "path"), [(HOST, ""), (HOST, "/"), ("", "share/scan"), ("", "\\\\srv")])
|
@pytest.mark.parametrize(("host", "path"), [(HOST, ""), (HOST, "/"), ("", "share/scan"), ("", "\\\\srv")])
|
||||||
@ -226,63 +243,13 @@ class TestPaths:
|
|||||||
|
|
||||||
|
|
||||||
class TestPut:
|
class TestPut:
|
||||||
def test_writes_part_in_tmp_then_renames_into_input(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_writes_directly_into_input(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
name = inbox_name(_job_id(), "aep_5.PDF")
|
name = inbox_name(_job_id(), "aep_5.PDF")
|
||||||
data = bytes(range(256)) * 10
|
data = bytes(range(256)) * 10
|
||||||
_hotfolder().put(name, data)
|
_hotfolder().put(name, data)
|
||||||
part = f"{TMP}\\.{name}.part"
|
assert fake.calls == [("mkdir", (INPUT,), {}), ("open_file", (f"{INPUT}\\{name}", "wb"), {})]
|
||||||
assert fake.calls == [
|
|
||||||
("mkdir", (TMP,), {}),
|
|
||||||
("mkdir", (INPUT,), {}),
|
|
||||||
("open_file", (part, "wb"), {}),
|
|
||||||
("rename", (part, f"{INPUT}\\{name}"), {}),
|
|
||||||
]
|
|
||||||
assert (share_root / "Input" / name).read_bytes() == data
|
assert (share_root / "Input" / name).read_bytes() == data
|
||||||
assert [p.name for p in (share_root / "Input").iterdir()] == [name]
|
assert sorted(p.name for p in share_root.iterdir()) == ["Input"] # Tmp не создаётся
|
||||||
assert list((share_root / "Tmp").iterdir()) == []
|
|
||||||
|
|
||||||
def test_input_has_no_part_during_write(self, fake: FakeSmbClient, share_root: Path) -> None:
|
|
||||||
"""OQ-79: пока пишется временный файл, во входящем каталоге его нет — он в `Tmp`."""
|
|
||||||
name = "a.pdf"
|
|
||||||
seen: dict[str, list[str]] = {}
|
|
||||||
real_rename = fake.rename
|
|
||||||
|
|
||||||
def spy(src: str, dst: str, **kwargs: Any) -> None:
|
|
||||||
seen["input"] = sorted(p.name for p in (share_root / "Input").iterdir())
|
|
||||||
seen["tmp"] = sorted(p.name for p in (share_root / "Tmp").iterdir())
|
|
||||||
real_rename(src, dst, **kwargs)
|
|
||||||
|
|
||||||
fake.rename = spy # type: ignore[method-assign]
|
|
||||||
_hotfolder().put(name, b"data")
|
|
||||||
assert seen == {"input": [], "tmp": [".a.pdf.part"]}
|
|
||||||
assert [p.name for p in (share_root / "Input").iterdir()] == [name]
|
|
||||||
|
|
||||||
def test_existing_tmp_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
|
|
||||||
(share_root / "Tmp").mkdir()
|
|
||||||
_hotfolder().put("a.pdf", b"1")
|
|
||||||
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
|
|
||||||
|
|
||||||
def test_tmp_mkdir_denied_but_tmp_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
|
|
||||||
(share_root / "Tmp").mkdir()
|
|
||||||
(share_root / "Input").mkdir()
|
|
||||||
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP)
|
|
||||||
_hotfolder().put("a.pdf", b"1")
|
|
||||||
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
|
|
||||||
assert fake.calls[:4] == [
|
|
||||||
("mkdir", (TMP,), {}),
|
|
||||||
("isdir", (TMP,), {}),
|
|
||||||
("mkdir", (INPUT,), {}),
|
|
||||||
("isdir", (INPUT,), {}),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_tmp_mkdir_denied_and_no_tmp_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
|
||||||
(share_root / "Input").mkdir()
|
|
||||||
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP)
|
|
||||||
fake.fail["mkdir"] = denied
|
|
||||||
with pytest.raises(SMBOSError) as excinfo:
|
|
||||||
_hotfolder().put("a.pdf", b"1")
|
|
||||||
assert excinfo.value is denied
|
|
||||||
assert fake.funcs() == ["mkdir", "isdir"]
|
|
||||||
|
|
||||||
def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
(share_root / "Input").mkdir()
|
(share_root / "Input").mkdir()
|
||||||
@ -298,41 +265,29 @@ class TestPut:
|
|||||||
|
|
||||||
def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
"""Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно."""
|
"""Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно."""
|
||||||
(share_root / "Tmp").mkdir()
|
|
||||||
(share_root / "Input").mkdir()
|
(share_root / "Input").mkdir()
|
||||||
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
_hotfolder().put("a.pdf", b"1")
|
_hotfolder().put("a.pdf", b"1")
|
||||||
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
|
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
|
||||||
assert fake.funcs() == ["mkdir", "isdir", "mkdir", "isdir", "open_file", "rename"]
|
assert fake.funcs() == ["mkdir", "isdir", "open_file"]
|
||||||
assert fake.calls[3] == ("isdir", (INPUT,), {})
|
|
||||||
|
|
||||||
def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
(share_root / "Tmp").mkdir()
|
|
||||||
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
fake.fail["mkdir"] = denied
|
fake.fail["mkdir"] = denied
|
||||||
with pytest.raises(SMBOSError) as excinfo:
|
with pytest.raises(SMBOSError) as excinfo:
|
||||||
_hotfolder().put("a.pdf", b"1")
|
_hotfolder().put("a.pdf", b"1")
|
||||||
assert excinfo.value is denied
|
assert excinfo.value is denied
|
||||||
assert fake.funcs() == ["mkdir", "isdir", "mkdir", "isdir"]
|
assert fake.funcs() == ["mkdir", "isdir"]
|
||||||
|
|
||||||
def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP)
|
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
fake.fail["mkdir"] = denied
|
fake.fail["mkdir"] = denied
|
||||||
fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP)
|
fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
with pytest.raises(SMBOSError) as excinfo:
|
with pytest.raises(SMBOSError) as excinfo:
|
||||||
_hotfolder().put("a.pdf", b"1")
|
_hotfolder().put("a.pdf", b"1")
|
||||||
assert excinfo.value is denied
|
assert excinfo.value is denied
|
||||||
assert fake.funcs() == ["mkdir", "isdir"]
|
assert fake.funcs() == ["mkdir", "isdir"]
|
||||||
|
|
||||||
@pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"])
|
|
||||||
def test_no_replace_on_existing_file(self, fake: FakeSmbClient, share_root: Path, plain: bool) -> None:
|
|
||||||
fake.plain_collision = plain
|
|
||||||
hf = _hotfolder()
|
|
||||||
hf.put("a.pdf", b"old")
|
|
||||||
hf.put("a.pdf", b"new")
|
|
||||||
assert (share_root / "Input" / "a.pdf").read_bytes() == b"old"
|
|
||||||
assert (share_root / "Tmp" / ".a.pdf.part").read_bytes() == b"new"
|
|
||||||
|
|
||||||
def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
_hotfolder().put("empty.pdf", b"")
|
_hotfolder().put("empty.pdf", b"")
|
||||||
assert (share_root / "Input" / "empty.pdf").read_bytes() == b""
|
assert (share_root / "Input" / "empty.pdf").read_bytes() == b""
|
||||||
@ -371,58 +326,61 @@ class TestPut:
|
|||||||
_hotfolder().put(name, b"x")
|
_hotfolder().put(name, b"x")
|
||||||
assert fake.calls == []
|
assert fake.calls == []
|
||||||
|
|
||||||
def test_cleans_part_file_on_rename_failure(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
|
||||||
fake.fail["rename"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
self, fake: FakeSmbClient, share_root: Path
|
||||||
with pytest.raises(OSError):
|
) -> None:
|
||||||
_hotfolder(cleanup=True).put("a.pdf", b"data")
|
_fail_write(fake)
|
||||||
part = f"{TMP}\\.a.pdf.part"
|
with pytest.raises(SMBOSError):
|
||||||
assert fake.calls[-1] == ("remove", (part,), {})
|
_hotfolder().put("a.pdf", b"data")
|
||||||
assert list((share_root / "Input").iterdir()) == []
|
assert (share_root / "Input" / "a.pdf").read_bytes() == b"da"
|
||||||
assert list((share_root / "Tmp").iterdir()) == []
|
assert "remove" not in fake.funcs()
|
||||||
|
|
||||||
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_write_failure_removes_partial_file_when_cleanup_enabled(
|
||||||
fake.fail["rename"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
self, fake: FakeSmbClient, share_root: Path
|
||||||
fake.fail["remove"] = SMBException("соединение потеряно")
|
) -> None:
|
||||||
|
_fail_write(fake)
|
||||||
with pytest.raises(SMBOSError):
|
with pytest.raises(SMBOSError):
|
||||||
_hotfolder(cleanup=True).put("a.pdf", b"data")
|
_hotfolder(cleanup=True).put("a.pdf", b"data")
|
||||||
assert fake.funcs() == ["mkdir", "mkdir", "open_file", "rename", "remove"]
|
assert fake.calls[-1] == ("remove", (f"{INPUT}\\a.pdf",), {})
|
||||||
|
assert not (share_root / "Input" / "a.pdf").exists()
|
||||||
|
|
||||||
|
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
original = _fail_write(fake)
|
||||||
|
fake.fail["remove"] = SMBException("соединение потеряно")
|
||||||
|
with pytest.raises(SMBOSError) as excinfo:
|
||||||
|
_hotfolder(cleanup=True).put("a.pdf", b"data")
|
||||||
|
assert excinfo.value is original
|
||||||
|
assert fake.funcs() == ["mkdir", "open_file", "remove"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cleanup", [False, True])
|
||||||
|
def test_open_failure_never_removes_existing_file(
|
||||||
|
self, fake: FakeSmbClient, share_root: Path, cleanup: bool
|
||||||
|
) -> None:
|
||||||
|
"""I-1: открыть не удалось (например, ИС держит файл повторённого задания) — недописанного файла нет, целый
|
||||||
|
файл прошлой доставки не трогаем даже при включённой очистке; ошибка открытия поднимается как есть."""
|
||||||
|
(share_root / "Input").mkdir()
|
||||||
|
(share_root / "Input" / "a.pdf").write_bytes(b"delivered")
|
||||||
|
denied = SMBOSError(NtStatus.STATUS_SHARING_VIOLATION, INPUT + "\\a.pdf")
|
||||||
|
fake.fail["open_file"] = denied
|
||||||
|
with pytest.raises(SMBOSError) as excinfo:
|
||||||
|
_hotfolder(cleanup=cleanup).put("a.pdf", b"new")
|
||||||
|
assert excinfo.value is denied
|
||||||
|
assert "remove" not in fake.funcs()
|
||||||
|
assert (share_root / "Input" / "a.pdf").read_bytes() == b"delivered"
|
||||||
|
|
||||||
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
_hotfolder(login="user", password="secret").put("a.pdf", b"1")
|
_hotfolder(login="user", password="secret").put("a.pdf", b"1")
|
||||||
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 4
|
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 2
|
||||||
|
|
||||||
|
|
||||||
class TestPutWithoutReplace:
|
class TestPutOverwrite:
|
||||||
def test_uses_rename_not_replace(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_existing_file_is_overwritten(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
"""Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл."""
|
||||||
hf = _hotfolder()
|
hf = _hotfolder()
|
||||||
hf.put("job.pdf", b"x")
|
hf.put("job.pdf", b"first, longer")
|
||||||
names = [call[0] for call in fake.calls]
|
|
||||||
assert "rename" in names and "replace" not in names
|
|
||||||
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"x"
|
|
||||||
assert not fake._local(TMP + "\\.job.pdf.part").exists()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"])
|
|
||||||
def test_existing_destination_kept_and_part_left(self, fake: FakeSmbClient, share_root: Path, plain: bool) -> None:
|
|
||||||
fake.plain_collision = plain
|
|
||||||
hf = _hotfolder()
|
|
||||||
hf.put("job.pdf", b"first")
|
|
||||||
hf.put("job.pdf", b"second")
|
hf.put("job.pdf", b"second")
|
||||||
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first"
|
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"second"
|
||||||
assert fake._local(TMP + "\\.job.pdf.part").read_bytes() == b"second"
|
assert "rename" not in fake.funcs() and "replace" not in fake.funcs()
|
||||||
|
|
||||||
@pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"])
|
|
||||||
def test_existing_destination_drops_part_when_cleanup_enabled(
|
|
||||||
self, fake: FakeSmbClient, share_root: Path, plain: bool
|
|
||||||
) -> None:
|
|
||||||
"""Назначение есть (повтор задания) и очистка включена: файл не заменён, `.part` снят."""
|
|
||||||
fake.plain_collision = plain
|
|
||||||
hf = _hotfolder(cleanup=True)
|
|
||||||
hf.put("job.pdf", b"first")
|
|
||||||
fake.calls.clear()
|
|
||||||
hf.put("job.pdf", b"second") # исключения нет: назначение есть — файл считается доставленным
|
|
||||||
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first"
|
|
||||||
assert not fake._local(TMP + "\\.job.pdf.part").exists()
|
|
||||||
assert fake.calls[-1] == ("remove", (f"{TMP}\\.job.pdf.part",), {})
|
|
||||||
|
|
||||||
|
|
||||||
class TestCleanup:
|
class TestCleanup:
|
||||||
@ -481,7 +439,7 @@ class TestCleanupDetails:
|
|||||||
hf.cleanup(_job_id(), name)
|
hf.cleanup(_job_id(), name)
|
||||||
assert fake.calls == []
|
assert fake.calls == []
|
||||||
|
|
||||||
def test_orphan_part_and_done_marker_are_removed(self, fake: FakeSmbClient) -> None:
|
def test_done_marker_is_removed_and_tmp_is_not_touched(self, fake: FakeSmbClient) -> None:
|
||||||
hf = self._hf(fake)
|
hf = self._hf(fake)
|
||||||
job_id = _job_id()
|
job_id = _job_id()
|
||||||
name = f"{job_id}.pdf"
|
name = f"{job_id}.pdf"
|
||||||
@ -490,14 +448,14 @@ class TestCleanupDetails:
|
|||||||
(root / folder).mkdir()
|
(root / folder).mkdir()
|
||||||
(root / "Input" / name).write_bytes(b"pdf")
|
(root / "Input" / name).write_bytes(b"pdf")
|
||||||
(root / "Input" / f"{name}.done").write_bytes(b"") # маркер ИС
|
(root / "Input" / f"{name}.done").write_bytes(b"") # маркер ИС
|
||||||
(root / "Tmp" / f".{name}.part").write_bytes(b"orphan") # `.part` осиротевшей записи
|
(root / "Tmp" / f".{name}.part").write_bytes(b"legacy") # остаток прежних версий шлюза — не наш
|
||||||
other = root / "Input" / f"{_job_id()}.pdf"
|
other = root / "Input" / f"{_job_id()}.pdf"
|
||||||
other.write_bytes(b"other job") # чужое задание не трогаем
|
other.write_bytes(b"other job") # чужое задание не трогаем
|
||||||
assert hf.cleanup(job_id, name) is True
|
assert hf.cleanup(job_id, name) is True
|
||||||
assert sorted(p.name for p in (root / "Input").iterdir()) == [other.name]
|
assert sorted(p.name for p in (root / "Input").iterdir()) == [other.name]
|
||||||
assert list((root / "Tmp").iterdir()) == []
|
assert (root / "Tmp" / f".{name}.part").exists()
|
||||||
removed = [args[0] for func, args, _ in fake.calls if func == "remove"]
|
removed = [args[0] for func, args, _ in fake.calls if func == "remove"]
|
||||||
assert removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done", f"{TMP}\\.{name}.part"]
|
assert removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done"]
|
||||||
|
|
||||||
def test_scandir_access_denied_propagates_and_nothing_else_is_touched(self, fake: FakeSmbClient) -> None:
|
def test_scandir_access_denied_propagates_and_nothing_else_is_touched(self, fake: FakeSmbClient) -> None:
|
||||||
hf = self._hf(fake)
|
hf = self._hf(fake)
|
||||||
@ -523,7 +481,7 @@ class TestCleanupDetails:
|
|||||||
assert excinfo.value.ntstatus == NtStatus.STATUS_DIRECTORY_NOT_EMPTY
|
assert excinfo.value.ntstatus == NtStatus.STATUS_DIRECTORY_NOT_EMPTY
|
||||||
assert not (answer / f"{job_id}.json").exists() # файлы сняты, каталог остался вместе с подкаталогом
|
assert not (answer / f"{job_id}.json").exists() # файлы сняты, каталог остался вместе с подкаталогом
|
||||||
assert (answer / "nested").is_dir()
|
assert (answer / "nested").is_dir()
|
||||||
assert fake.funcs()[-1] == "rmdir" # дальше (входящий файл, `.done`, `.part`) очистка не пошла
|
assert fake.funcs()[-1] == "rmdir" # дальше (входящий файл, `.done`) очистка не пошла
|
||||||
|
|
||||||
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient) -> None:
|
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient) -> None:
|
||||||
hf = self._hf(fake, login="user", password="secret")
|
hf = self._hf(fake, login="user", password="secret")
|
||||||
@ -536,13 +494,13 @@ class TestCleanupDetails:
|
|||||||
(answer / f"{job_id}_ofr.json").write_bytes(b"{}")
|
(answer / f"{job_id}_ofr.json").write_bytes(b"{}")
|
||||||
fake.calls.clear()
|
fake.calls.clear()
|
||||||
assert hf.cleanup(job_id, name) is True
|
assert hf.cleanup(job_id, name) is True
|
||||||
assert fake.funcs() == ["scandir", "remove", "remove", "rmdir", "remove", "remove", "remove"]
|
assert fake.funcs() == ["scandir", "remove", "remove", "rmdir", "remove", "remove"]
|
||||||
assert all(kwargs == {"username": "user", "password": "secret"} for _, _, kwargs in fake.calls)
|
assert all(kwargs == {"username": "user", "password": "secret"} for _, _, kwargs in fake.calls)
|
||||||
|
|
||||||
def test_missing_answer_dir_and_files_are_fine(self, fake: FakeSmbClient) -> None:
|
def test_missing_answer_dir_and_files_are_fine(self, fake: FakeSmbClient) -> None:
|
||||||
hf = self._hf(fake)
|
hf = self._hf(fake)
|
||||||
assert hf.cleanup(_job_id(), "nothing.pdf") is True
|
assert hf.cleanup(_job_id(), "nothing.pdf") is True
|
||||||
assert fake.funcs() == ["scandir", "remove", "remove", "remove"]
|
assert fake.funcs() == ["scandir", "remove", "remove"]
|
||||||
|
|
||||||
def test_answer_dir_is_a_file_is_fine(self, fake: FakeSmbClient) -> None:
|
def test_answer_dir_is_a_file_is_fine(self, fake: FakeSmbClient) -> None:
|
||||||
hf = self._hf(fake)
|
hf = self._hf(fake)
|
||||||
|
|||||||
@ -953,13 +953,14 @@ class TestSlice3a:
|
|||||||
assert "Очистка горячей папки не удалась" in warnings[0] and job_id in warnings[0] and "нет прав" in warnings[0]
|
assert "Очистка горячей папки не удалась" in warnings[0] and job_id in warnings[0] and "нет прав" in warnings[0]
|
||||||
|
|
||||||
async def test_existing_input_waits_for_answer(self, make_env: EnvFactory) -> None:
|
async def test_existing_input_waits_for_answer(self, make_env: EnvFactory) -> None:
|
||||||
"""Review Focus 4: входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT, файл не перезаписан."""
|
"""Входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT; файл перезаписан объектом S3, как в старом
|
||||||
|
АРМ (change hotfolder-direct-write, решение владельца 09.10.2026)."""
|
||||||
env = await make_env()
|
env = await make_env()
|
||||||
body = env.request(deadline_s=0.3)
|
body = env.request(deadline_s=0.3)
|
||||||
env.local.put(f"{body['job_id']}.pdf", b"already there")
|
env.local.put(f"{body['job_id']}.pdf", b"already there")
|
||||||
result = await env.run(body)
|
result = await env.run(body)
|
||||||
assert result["error"]["code"] == "TIMEOUT"
|
assert result["error"]["code"] == "TIMEOUT"
|
||||||
assert (env.local.input_dir / f"{body['job_id']}.pdf").read_bytes() == b"already there"
|
assert (env.local.input_dir / f"{body['job_id']}.pdf").read_bytes() == PDF_BYTES
|
||||||
assert env.inbox() == [f"{body['job_id']}.pdf"]
|
assert env.inbox() == [f"{body['job_id']}.pdf"]
|
||||||
|
|
||||||
async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None:
|
async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None:
|
||||||
|
|||||||
359
docs/superpowers/plans/2026-10-09-hotfolder-direct-write.md
Normal file
359
docs/superpowers/plans/2026-10-09-hotfolder-direct-write.md
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
# Запись в горячую папку прямо в Input — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (план из 3 задач, ≤5 — Native по
|
||||||
|
> `~/.claude/CLAUDE.md`). Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** `put` пишет `<job_id>.<ext>` сразу в `Input` (`wb`, перезапись), без `Tmp/.part` и rename; `SCAN_TMP_DIR`
|
||||||
|
уходит из шлюза; `SCAN_TIMEOUT_SECONDS` по умолчанию 1200.
|
||||||
|
|
||||||
|
**Architecture:** Меняются только `SmbHotFolder.put/cleanup` и `LocalHotFolder.put/cleanup`; протокол `HotFolder`,
|
||||||
|
воркер и эмулятор не меняются. `LocalHotFolder.tmp_dir` остаётся для эмулятора.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11, smbprotocol (`smbclient`), pytest, ruff, mypy.
|
||||||
|
|
||||||
|
**Spec:** `openspec/changes/hotfolder-direct-write/` (proposal, design D-1…D-5, R-1…R-4, specs/image-recognition-gateway)
|
||||||
|
|
||||||
|
Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway` (ветка
|
||||||
|
`feat/26-hotfolder-direct-write`). Git — `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway`.
|
||||||
|
`pytest` — из `api/` (`.venv/bin/pytest`), `make` — из корня репозитория.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Запись: `smbclient.open_file(<Input>\<имя>, mode="wb", **credentials)` / `open(<input>/<имя>, "wb")`; существующий файл перезаписывается.
|
||||||
|
- Временных файлов, `Tmp` и rename у шлюза нет; `Input` создаётся терпимо (`_ensure_dir` / `mkdir(exist_ok=True)`), корень — нет.
|
||||||
|
- Ошибка записи: недописанный `<Input>/<имя>` снимается только при `cleanup=True`; ошибка снятия подавляется, поднимается исходная.
|
||||||
|
- При `SCAN_CLEANUP=false` ничего не удаляется; `cleanup` удаляет `Output/<job_id>/`, `Input/<имя>`, `Input/<имя>.done` — и больше ничего.
|
||||||
|
- `SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200`; `SCAN_TMP_DIR` в `Settings` нет (у эмулятора — есть).
|
||||||
|
- Учётные данные передаются в каждый вызов `smbclient` (как сейчас).
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
- Повторный `put` того же имени с другим содержимым → файл перезаписан новым содержимым (оба режима).
|
||||||
|
- Ошибка посреди `write` при `cleanup=False` → обрезанный файл остаётся, исключение исходное.
|
||||||
|
- Ошибка посреди `write` при `cleanup=True` и падающем `remove` → поднимается исходная ошибка записи.
|
||||||
|
- Старый `.env` с `SCAN_TMP_DIR=Tmp` → шлюз стартует (`extra="ignore"`).
|
||||||
|
- `put` не создаёт `Tmp` ни в одном режиме; эмулятор по-прежнему собирает ответ в `Tmp`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `SmbHotFolder` — прямая запись
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `api/src/gateway/smb.py` (докстринг модуля, `__init__`, `put`, `_drop_part` → `_drop_partial`, `cleanup`)
|
||||||
|
- Test: `api/tests/unit/test_smb_hotfolder.py` (`TMP`, `TestPaths`, `TestPut`, `TestPutWithoutReplace` → `TestPutOverwrite`, `TestCleanup*`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `SmbHotFolder(host, path, input_dir="Input", output_dir="Output", *, login="", password="", settle_seconds=2, clock=..., cleanup=False)` — **без** `tmp_dir` и атрибута `tmp_dir`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Переписать тесты `put` и `cleanup`**
|
||||||
|
|
||||||
|
Удалить константу `TMP`, из `TestPaths.test_default_layout` — `assert hf.tmp_dir == TMP`, из `test_custom_dir_names` —
|
||||||
|
`tmp_dir=...` и его assert. `TestPut` и `TestPutWithoutReplace` заменить на:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TestPut:
|
||||||
|
def test_writes_directly_into_input(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
name = inbox_name(_job_id(), "aep_5.PDF")
|
||||||
|
data = bytes(range(256)) * 10
|
||||||
|
_hotfolder().put(name, data)
|
||||||
|
assert fake.calls == [("mkdir", (INPUT,), {}), ("open_file", (f"{INPUT}\\{name}", "wb"), {})]
|
||||||
|
assert (share_root / "Input" / name).read_bytes() == data
|
||||||
|
assert sorted(p.name for p in share_root.iterdir()) == ["Input"] # Tmp не создаётся
|
||||||
|
|
||||||
|
def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
(share_root / "Input").mkdir()
|
||||||
|
_hotfolder().put("a.pdf", b"1")
|
||||||
|
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
|
||||||
|
|
||||||
|
def test_missing_root_raises_and_writes_nothing(self, fake: FakeSmbClient) -> None:
|
||||||
|
with pytest.raises(OSError) as excinfo:
|
||||||
|
_hotfolder().put("a.pdf", b"1")
|
||||||
|
assert isinstance(excinfo.value, SMBOSError)
|
||||||
|
assert fake.funcs() == ["mkdir"]
|
||||||
|
assert not fake.base.exists()
|
||||||
|
|
||||||
|
def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
"""Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно."""
|
||||||
|
(share_root / "Input").mkdir()
|
||||||
|
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
|
_hotfolder().put("a.pdf", b"1")
|
||||||
|
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
|
||||||
|
assert fake.funcs() == ["mkdir", "isdir", "open_file"]
|
||||||
|
|
||||||
|
def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
|
fake.fail["mkdir"] = denied
|
||||||
|
with pytest.raises(SMBOSError) as excinfo:
|
||||||
|
_hotfolder().put("a.pdf", b"1")
|
||||||
|
assert excinfo.value is denied
|
||||||
|
assert fake.funcs() == ["mkdir", "isdir"]
|
||||||
|
|
||||||
|
def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
|
fake.fail["mkdir"] = denied
|
||||||
|
fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
|
with pytest.raises(SMBOSError) as excinfo:
|
||||||
|
_hotfolder().put("a.pdf", b"1")
|
||||||
|
assert excinfo.value is denied
|
||||||
|
assert fake.funcs() == ["mkdir", "isdir"]
|
||||||
|
|
||||||
|
def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
_hotfolder().put("empty.pdf", b"")
|
||||||
|
assert (share_root / "Input" / "empty.pdf").read_bytes() == b""
|
||||||
|
|
||||||
|
def test_spaces_and_cyrillic_are_accepted(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
name = inbox_name(_job_id(), "Отчёт за 2024 год (копия).pdf")
|
||||||
|
_hotfolder().put(name, b"x")
|
||||||
|
assert (share_root / "Input" / name).read_bytes() == b"x"
|
||||||
|
|
||||||
|
# test_rejects_invalid_names_before_any_call — без изменений
|
||||||
|
|
||||||
|
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
|
||||||
|
self, fake: FakeSmbClient, share_root: Path
|
||||||
|
) -> None:
|
||||||
|
fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
|
with pytest.raises(SMBOSError):
|
||||||
|
_hotfolder().put("a.pdf", b"data")
|
||||||
|
assert "remove" not in fake.funcs()
|
||||||
|
|
||||||
|
def test_write_failure_removes_partial_file_when_cleanup_enabled(
|
||||||
|
self, fake: FakeSmbClient, share_root: Path
|
||||||
|
) -> None:
|
||||||
|
fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
|
with pytest.raises(SMBOSError):
|
||||||
|
_hotfolder(cleanup=True).put("a.pdf", b"data")
|
||||||
|
assert fake.calls[-1] == ("remove", (f"{INPUT}\\a.pdf",), {})
|
||||||
|
|
||||||
|
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
original = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
|
fake.fail["open_file"] = original
|
||||||
|
fake.fail["remove"] = SMBException("соединение потеряно")
|
||||||
|
with pytest.raises(SMBOSError) as excinfo:
|
||||||
|
_hotfolder(cleanup=True).put("a.pdf", b"data")
|
||||||
|
assert excinfo.value is original
|
||||||
|
assert fake.funcs() == ["mkdir", "open_file", "remove"]
|
||||||
|
|
||||||
|
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
_hotfolder(login="user", password="secret").put("a.pdf", b"1")
|
||||||
|
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestPutOverwrite:
|
||||||
|
def test_existing_file_is_overwritten(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
|
"""Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл."""
|
||||||
|
hf = _hotfolder()
|
||||||
|
hf.put("job.pdf", b"first, longer")
|
||||||
|
hf.put("job.pdf", b"second")
|
||||||
|
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"second"
|
||||||
|
assert "rename" not in fake.funcs() and "replace" not in fake.funcs()
|
||||||
|
```
|
||||||
|
|
||||||
|
В `TestCleanupDetails.test_orphan_part_and_done_marker_are_removed` → переименовать в
|
||||||
|
`test_done_marker_is_removed_and_tmp_is_not_touched`: `.part` в `Tmp` **остаётся**,
|
||||||
|
`removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done"]`, assert `(root / "Tmp" / f".{name}.part").exists()`.
|
||||||
|
`test_passes_credentials_to_every_call` (cleanup): `["scandir", "remove", "remove", "rmdir", "remove", "remove"]`.
|
||||||
|
`test_missing_answer_dir_and_files_are_fine`: `["scandir", "remove", "remove"]`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Прогнать — ожидаемо падают**
|
||||||
|
|
||||||
|
Run: `cd api && .venv/bin/pytest -q tests/unit/test_smb_hotfolder.py`
|
||||||
|
Expected: FAIL (`TestPut`, `TestPutOverwrite`, `TestCleanupDetails`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Реализация `smb.py`**
|
||||||
|
|
||||||
|
`__init__`: убрать параметр `tmp_dir` и `self.tmp_dir`. `put`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def put(self, name: str, data: bytes) -> None:
|
||||||
|
"""Записать `<input>\\<name>` сразу, открытием `wb` (как в старом АРМ, решение владельца 09.10.2026): временного
|
||||||
|
файла и rename нет, существующий файл перезаписывается. `smbclient.open_file` открывает файл монопольно — пока
|
||||||
|
запись идёт, ИС его не прочитает (ASSUMPTION(OQ-79)). Имя проверяется до любого обращения к шаре. При ошибке
|
||||||
|
записи недописанный файл снимается только при включённой очистке (`SCAN_CLEANUP`, R-3); ошибка снятия исходную
|
||||||
|
не маскирует. Нет корня — `SMBOSError` (ENOENT) от `mkdir`.
|
||||||
|
"""
|
||||||
|
validate_name(name)
|
||||||
|
self._ensure_dir(self.input_dir)
|
||||||
|
target = ntpath.join(self.input_dir, name)
|
||||||
|
try:
|
||||||
|
with smbclient.open_file(target, mode="wb", **self._credentials) as fd:
|
||||||
|
fd.write(data)
|
||||||
|
except BaseException:
|
||||||
|
self._drop_partial(target)
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
`_drop_part` переименовать в `_drop_partial` (тело то же, докстринг «недописанный входящий файл»). В `cleanup` из
|
||||||
|
кортежа путей убрать `ntpath.join(self.tmp_dir, f".{name}.part")`; докстринг — «ответ из `output`, входящий файл и
|
||||||
|
`.done`». Импорт `NtStatus` и `_has_errno(..., EEXIST ...)` в `put` больше не нужны — удалить, если не используются
|
||||||
|
(`_NOT_READY` использует `NtStatus` — оставить). Докстринг модуля: «файл пишется сразу в `\\<host>\<SMB_PATH>\<input_dir>\<имя>`
|
||||||
|
(`wb`, перезапись, как в старом АРМ)»; убрать упоминания `tmp_dir`/`.part`/OQ-79-rename. Комментарий `noqa: PLR0913`
|
||||||
|
у `__init__` оставить, если ruff его требует (параметров по-прежнему > 5).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Прогнать**
|
||||||
|
|
||||||
|
Run: `cd api && .venv/bin/pytest -q tests/unit/test_smb_hotfolder.py`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add api/src/gateway/smb.py api/tests/unit/test_smb_hotfolder.py
|
||||||
|
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "feat(smb): запись прямо в Input с перезаписью, как в старом АРМ (#26)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: `LocalHotFolder`, фабрика, настройки
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `api/src/gateway/hotfolder.py` (докстринг модуля, `LocalHotFolder` докстринг, `put`, удалить `_open_part`/`_replace_part`, `_drop_part` → `_drop_partial`, `cleanup`, `_NO_HARD_LINKS`)
|
||||||
|
- Modify: `api/src/gateway/__init__.py` (не передавать `SCAN_TMP_DIR`), `api/src/core/config.py` (`SCAN_TMP_DIR` удалить, `SCAN_TIMEOUT_SECONDS = 1200`, комментарий `SCAN_CLEANUP`)
|
||||||
|
- Test: `api/tests/unit/test_local_hotfolder.py`, `api/tests/unit/test_hotfolder_factory.py`, `api/tests/unit/test_config.py`, `api/tests/integration/test_roundtrip.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `SmbHotFolder` без `tmp_dir` (Task 1).
|
||||||
|
- Produces: `LocalHotFolder(root, input_dir="Input", output_dir="Output", tmp_dir="Tmp", *, ...)` — сигнатура та же,
|
||||||
|
`tmp_dir` используется только эмулятором; `Settings` без `SCAN_TMP_DIR`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Тесты**
|
||||||
|
|
||||||
|
`test_local_hotfolder.py`: удалить `TestPutWithoutReplace`, `TestPartIsCreatedFresh` и из `TestPut` —
|
||||||
|
`test_leaves_no_part_files`, `test_part_is_written_in_tmp_not_in_input`, `test_existing_tmp_dir_is_fine`,
|
||||||
|
`test_custom_tmp_dir_name`, `test_cleans_part_file_on_failure`, `test_keeps_part_file_on_failure_when_cleanup_disabled`,
|
||||||
|
`test_successful_put_despite_final_unlink_failure`, `test_cleanup_failure_does_not_mask_original_error`;
|
||||||
|
`test_creates_input_and_tmp_dirs_only` → `test_creates_input_dir_only` (`sorted(p.name for p in root.iterdir()) == ["Input"]`).
|
||||||
|
Добавить в `TestPut`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_existing_file_is_overwritten(self, tmp_path: Path) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path, settle_seconds=0)
|
||||||
|
hf.put("job.pdf", b"first, longer")
|
||||||
|
hf.put("job.pdf", b"second")
|
||||||
|
assert (hf.input_dir / "job.pdf").read_bytes() == b"second"
|
||||||
|
|
||||||
|
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path, settle_seconds=0)
|
||||||
|
monkeypatch.setattr(Path, "write_bytes", _failing_write)
|
||||||
|
with pytest.raises(OSError, match="диск полон"):
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
assert (hf.input_dir / "job.pdf").read_bytes() == b"da"
|
||||||
|
|
||||||
|
def test_write_failure_removes_partial_file_when_cleanup_enabled(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
|
||||||
|
monkeypatch.setattr(Path, "write_bytes", _failing_write)
|
||||||
|
with pytest.raises(OSError, match="диск полон"):
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
assert not (hf.input_dir / "job.pdf").exists()
|
||||||
|
|
||||||
|
def test_remove_failure_does_not_mask_write_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
|
||||||
|
monkeypatch.setattr(Path, "write_bytes", _failing_write)
|
||||||
|
monkeypatch.setattr(Path, "unlink", _failing_unlink)
|
||||||
|
with pytest.raises(OSError, match="диск полон"):
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
```
|
||||||
|
|
||||||
|
с модульными помощниками:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _failing_write(self: Path, data: bytes) -> int:
|
||||||
|
"""Недописанный файл: на диск попадают первые два байта, затем ошибка ввода-вывода."""
|
||||||
|
with open(self, "wb") as handle:
|
||||||
|
handle.write(data[:2])
|
||||||
|
raise OSError(errno.ENOSPC, "диск полон")
|
||||||
|
|
||||||
|
|
||||||
|
def _failing_unlink(self: Path, missing_ok: bool = False) -> None:
|
||||||
|
raise PermissionError(errno.EACCES, "нет прав")
|
||||||
|
```
|
||||||
|
|
||||||
|
`TestCleanup`: ожидания про `Tmp/.<name>.part` — `.part` **не удаляется** (файл в `Tmp` остаётся, если тест его создаёт).
|
||||||
|
`test_hotfolder_factory.py`: в `_local_settings` убрать `SCAN_TMP_DIR`; assert'ы `hf.tmp_dir == tmp_path / "Служебный"`
|
||||||
|
и `hf.tmp_dir == "...\\T"` удалить, для local — `assert hf.tmp_dir == tmp_path / "Tmp"` (умолчание эмулятора).
|
||||||
|
`test_config.py`: строки `assert s.SCAN_TMP_DIR == "Tmp"` → `assert not hasattr(s, "SCAN_TMP_DIR")`; добавить
|
||||||
|
`assert s.SCAN_TIMEOUT_SECONDS == 1200` в тест умолчаний (строка 32) и тест:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_legacy_scan_tmp_dir_is_ignored(monkeypatch):
|
||||||
|
monkeypatch.setenv("SCAN_TMP_DIR", "Tmp")
|
||||||
|
s = Settings(_env_file=None)
|
||||||
|
assert not hasattr(s, "SCAN_TMP_DIR")
|
||||||
|
```
|
||||||
|
|
||||||
|
(обязательные переменные — как в соседних тестах файла; взять их фикстуру/хелпер). `test_roundtrip.py`: убрать
|
||||||
|
`stray_parts`, `tmp_dir` у объекта стенда, `tmp_dir=settings.SCAN_TMP_DIR` (эмулятору передать `"Tmp"`), докстринги
|
||||||
|
про `.part`; в сценарии очистки — `Input/` и `Output/<job_id>/` пусты.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Прогнать — ожидаемо падают**
|
||||||
|
|
||||||
|
Run: `cd api && .venv/bin/pytest -q tests/unit/test_local_hotfolder.py tests/unit/test_hotfolder_factory.py tests/unit/test_config.py`
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Реализация**
|
||||||
|
|
||||||
|
`LocalHotFolder.put`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def put(self, name: str, data: bytes) -> None:
|
||||||
|
"""Записать `input_dir/<name>` сразу (`wb`, перезапись существующего, как в старом АРМ — решение владельца
|
||||||
|
09.10.2026). `Input` создаётся без родителей, корень — нет (нет корня — `FileNotFoundError`). При ошибке записи
|
||||||
|
недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется."""
|
||||||
|
validate_name(name)
|
||||||
|
self.input_dir.mkdir(exist_ok=True)
|
||||||
|
target = self.input_dir / name
|
||||||
|
try:
|
||||||
|
target.write_bytes(data)
|
||||||
|
except BaseException:
|
||||||
|
self._drop_partial(target)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _drop_partial(self, path: Path) -> None:
|
||||||
|
if self.cleanup_enabled:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
Удалить `_open_part`, `_replace_part`, `_NO_HARD_LINKS` и неиспользуемые импорты (`errno`, `os` — проверить ruff).
|
||||||
|
`cleanup`: кортеж путей без `self.tmp_dir / f".{name}.part"`. Докстринги модуля и класса: прямая запись в `Input`;
|
||||||
|
`tmp_dir` — служебный каталог эмулятора (сборка набора ответа), `put` его не использует.
|
||||||
|
`gateway/__init__.py`: в `LocalHotFolder(...)` не передавать `settings.SCAN_TMP_DIR` (умолчание `"Tmp"`), в
|
||||||
|
`SmbHotFolder(...)` убрать аргумент. `config.py`: удалить `SCAN_TMP_DIR`; `SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200`
|
||||||
|
(комментарий: «20 мин, в паре со сроком ответа OCR ядра»); комментарий `SCAN_CLEANUP` — без `.part в Tmp`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Прогнать unit**
|
||||||
|
|
||||||
|
Run: `cd api && .venv/bin/pytest -q tests/unit`
|
||||||
|
Expected: PASS (включая `test_emulator.py` — эмулятор пользуется `tmp_dir`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add -A api
|
||||||
|
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "feat(hotfolder): local тоже пишет прямо в Input; без SCAN_TMP_DIR; таймаут 1200 с (#26)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Документация и полная проверка
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md` (схема конвейера п. 5, раздел протокола горячих папок, «Очистка», таблица env: убрать `SCAN_TMP_DIR` шлюза, `SCAN_TIMEOUT_SECONDS` = `1200`, права на SMB), `CLAUDE.md` («Что это», «Горячие папки»), `api/.env.example` (`SCAN_TMP_DIR` — в блок эмулятора, `SCAN_TIMEOUT_SECONDS`), `openspec/changes/hotfolder-direct-write/tasks.md` (отметки)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Правки текста**
|
||||||
|
|
||||||
|
`git grep -n "Tmp\|\.part\|SCAN_TMP_DIR\|SCAN_TIMEOUT_SECONDS\|300" -- README.md CLAUDE.md api/.env.example` и привести
|
||||||
|
каждое место к: «файл пишется сразу в `Input/<job_id>.<ext>` (`wb`, существующий перезаписывается, как в старом АРМ;
|
||||||
|
ИС не берёт файл, пока он открыт на запись — ASSUMPTION(OQ-79))»; `Tmp` и `SCAN_TMP_DIR` упоминаются только у
|
||||||
|
эмулятора; права учётки шары — запись в `Input`, чтение `Output`, удаление при `SCAN_CLEANUP`; таймаут 1200 с.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Полная проверка**
|
||||||
|
|
||||||
|
Run: `make check-full` (из корня репозитория; нужен поднятый стенд `make up`)
|
||||||
|
Expected: exit 0. Вывод — в описание PR.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add -A README.md CLAUDE.md api/.env.example openspec
|
||||||
|
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "docs: прямая запись в Input, SCAN_TMP_DIR только у эмулятора, таймаут 1200 с (#26)"
|
||||||
|
```
|
||||||
2
openspec/changes/hotfolder-direct-write/.openspec.yaml
Normal file
2
openspec/changes/hotfolder-direct-write/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-09
|
||||||
62
openspec/changes/hotfolder-direct-write/design.md
Normal file
62
openspec/changes/hotfolder-direct-write/design.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Запись входящего файла сейчас — двухфазная: `Tmp/.<имя>.part`, затем rename (SMB) / жёсткая ссылка (local) в `Input`
|
||||||
|
без замены (решения №17, №26, №32, №35 Среза 3a). Старый АРМ писал файл прямо в `Input` и в контуре работал. Владелец
|
||||||
|
09.10.2026 выбрал повторить старый АРМ и перезаписывать существующий файл.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** прямая запись в `Input` в обоих режимах; меньше требований к правам на шаре; срок ожидания ответа 20 мин.
|
||||||
|
|
||||||
|
**Non-Goals:** см. proposal.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D-1. Прямая запись с усечением (решение владельца 09.10.2026)
|
||||||
|
|
||||||
|
`SmbHotFolder.put`: `smbclient.open_file(<Input>\<имя>, mode="wb", **credentials)` и `write`. `LocalHotFolder.put`:
|
||||||
|
`open(<input>/<имя>, "wb")` (`Path.write_bytes`). Существующий файл перезаписывается. Повторная запись в рамках одного
|
||||||
|
процесса по-прежнему исключена дедупликацией по `job_id` в воркере (требование «Подтверждение запроса до обработки»);
|
||||||
|
перезапись возможна только при повторе задания ядром в другом процессе.
|
||||||
|
|
||||||
|
### D-2. Каталог `Input` создаётся, как раньше
|
||||||
|
|
||||||
|
Перед записью `Input` создаётся без родителей, «уже есть» и отказ в правах при существующем каталоге — не ошибка
|
||||||
|
(`_ensure_dir` в `smb`, `mkdir(exist_ok=True)` в `local`); корень не создаётся. Старый АРМ каталог не создавал, но
|
||||||
|
терпимое создание поведения в контуре не меняет, а dev-режим и тесты на нём держатся.
|
||||||
|
|
||||||
|
### D-3. `Tmp` остаётся только у эмулятора
|
||||||
|
|
||||||
|
`SCAN_TMP_DIR` удаляется из настроек шлюза (`Settings`, `extra="ignore"` — старое значение в `.env` не ломает старт).
|
||||||
|
Эмулятор читает свою переменную `SCAN_TMP_DIR` (`EmulatorSettings`) и собирает набор ответа через
|
||||||
|
`LocalHotFolder.tmp_dir`, поэтому параметр `tmp_dir` у `LocalHotFolder` остаётся, но `put` его не использует.
|
||||||
|
|
||||||
|
### D-4. Срок ожидания 20 мин
|
||||||
|
|
||||||
|
`SCAN_TIMEOUT_SECONDS` по умолчанию 1200. Эффективный дедлайн — `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`,
|
||||||
|
поэтому arm-core поднимает срок ответа OCR до 20 мин отдельным PR (RSHB/arm-core#87).
|
||||||
|
|
||||||
|
### D-5. Ошибка записи
|
||||||
|
|
||||||
|
При исключении во время открытия/записи `put` снимает недописанный `<Input>/<имя>` только при включённой очистке
|
||||||
|
(ошибка снятия подавляется, исходное исключение поднимается) — R-3.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- ИС может увидеть недописанный файл (OQ-79) → смягчение: монопольное открытие `smbclient` на время записи; так же
|
||||||
|
работал старый АРМ. Откат — вернуть двухфазную запись из истории ветки.
|
||||||
|
- Ошибка посреди записи оставляет обрезанный файл в `Input` → удаляется только при `SCAN_CLEANUP=true` (как `.part`
|
||||||
|
раньше, D-5); иначе остаётся, конверт `RECOGNITION_FAILED`.
|
||||||
|
|
||||||
|
## Decisions made without discussion
|
||||||
|
|
||||||
|
| # | Дата | Решение | Обоснование | Цена ошибки |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| R-1 | 2026-10-09 | Режим `local` тоже пишет прямо в `Input` (не только `smb`) | Одинаковая семантика режимов, один набор тестов; эмулятор пишет/читает локально мгновенно | Эмулятор может прочитать недописанный файл — на локальной ФС практически невозможно; откат — вернуть `.part` в `local` |
|
||||||
|
| R-2 | 2026-10-09 | `Input` по-прежнему создаётся перед записью (D-2), хотя старый АРМ этого не делал | Отказ в правах на `mkdir` при существующем каталоге уже не ошибка; dev и тесты на этом держатся | Лишний вызов `mkdir` на шаре на каждое задание |
|
||||||
|
| R-3 | 2026-10-09 | Ошибка записи: недописанный файл в `Input` удаляется только при `SCAN_CLEANUP=true` (D-5) | Требование «при `SCAN_CLEANUP=false` ничего не удалять» (OQ-144) сохраняется | Обрезанный файл остаётся в `Input`, ИС может его распознать с ошибкой; ядро получит `RECOGNITION_FAILED` от шлюза раньше |
|
||||||
|
| R-4 | 2026-10-09 | `SCAN_TMP_DIR` удаляется из шлюза, у эмулятора остаётся (D-3) | Шлюзу каталог больше не нужен; эмулятору нужен для атомарной сборки ответа | Нет: `extra="ignore"` — старые `.env` стартуют |
|
||||||
|
| R-5 | 2026-10-09 | Правка вызова `SmbHotFolder` в фабрике (`gateway/__init__.py`) перенесена из задачи 2 плана в задачу 1 | Без неё mypy красный между задачами (лишний позиционный аргумент) | Нет |
|
||||||
|
| R-6 | 2026-10-09 | Сверх плана: `tests/unit/conftest.py` не передаёт `SCAN_TMP_DIR`; `test_worker.py::test_existing_input_waits_for_answer` ждёт перезаписи объектом S3; в `test_roundtrip.py` эмулятору передаётся литерал `"Tmp"` | Прямые следствия D-1 и D-3 | Нет |
|
||||||
|
| R-7 | 2026-10-09 | Финальное ревью I-1: ошибка **открытия** входящего файла ничего не удаляет (открытие вынесено из блока снятия недописанного файла); снимается только файл, сбойнувший после открытия | Иначе при `SCAN_CLEANUP=true` удалялся бы целый файл прошлой доставки, который, возможно, читает ИС | Нет |
|
||||||
|
| R-8 | 2026-10-09 | Финальное ревью I-1(б): если при повторе задания в другом процессе файл занят ИС (`STATUS_SHARING_VIOLATION` на открытии), шлюз публикует `RECOGNITION_FAILED`, а не считает файл доставленным — как старый АРМ (`wb` без особых случаев) | Решение владельца «как в старом АРМ»; повтор в другом процессе — только после потери ack (редко) | Ядро может получить `RECOGNITION_FAILED` раньше успешного ответа первого экземпляра (OCR `max_attempts` = 1) — аналитику придётся перезагрузить файл; вынесено вопросом владельцу |
|
||||||
43
openspec/changes/hotfolder-direct-write/proposal.md
Normal file
43
openspec/changes/hotfolder-direct-write/proposal.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Решение владельца 09.10.2026: запись входящего файла в горячую папку ИС Сканирование — «как в старом АРМ», где она
|
||||||
|
работала в контуре: `smbclient.open_file('<SMB_PATH>/Input/<имя>', 'wb')` прямо во входящий каталог
|
||||||
|
(`reference-old-arm/api/src/core/clients.py:111`). Нынешняя схема (временный `Tmp/.<имя>.part` + rename без замены,
|
||||||
|
OQ-79, OQ-137) требует от учётной записи шары каталога `Tmp`, прав на его создание и на переименование и в контуре не
|
||||||
|
проверялась. Там же владелец решил: срок ожидания ответа OCR — 20 минут.
|
||||||
|
Issue — RSHB/arm-scan-gateway#26. Вне среза `09-delivery-plan.md` (доработка Среза 3a по эксплуатации).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- `put` (режимы `smb` и `local`): файл `<job_id>.<ext>` пишется сразу во входящий каталог, открытием на запись с
|
||||||
|
усечением (`wb`); существующий файл **перезаписывается** (как в старом АРМ). Временного файла, каталога `Tmp` и
|
||||||
|
переименования больше нет.
|
||||||
|
- Настройка шлюза `SCAN_TMP_DIR` удаляется; параметр `tmp_dir` у `SmbHotFolder` удаляется. У `LocalHotFolder` он остаётся
|
||||||
|
только для эмулятора (сборка набора ответа, переменная эмулятора `SCAN_TMP_DIR` не меняется).
|
||||||
|
- Очистка (`SCAN_CLEANUP=true`) больше не удаляет `Tmp/.<job_id>.<ext>.part`.
|
||||||
|
- `SCAN_TIMEOUT_SECONDS` по умолчанию 300 → 1200 (20 мин) — в паре с `reply_deadline` OCR 20 мин в arm-core
|
||||||
|
(RSHB/arm-core#87).
|
||||||
|
- README, `CLAUDE.md`, `api/.env.example`, тесты — под новую семантику.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Протокол ответа (`Output/<job_id>/*.json`, стабилизация набора, OQ-78) не меняется.
|
||||||
|
- Эмулятор ИС не меняется: он по-прежнему пропускает `*.part`/`*.tmp`/dot-файлы и собирает ответ через свой `Tmp`.
|
||||||
|
- Аутентификация SMB не меняется: `OPENBAO__SMB__SMB_LOGIN`/`SMB_PASSWORD` уже передаются в сессию и в каждый вызов.
|
||||||
|
- Таймаут вызовов `smbclient` (соединение ~60 с по умолчанию библиотеки) не трогаем.
|
||||||
|
- Таблицы DBML не затрагиваются.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `image-recognition-gateway`: запись входящего файла без временного файла и с перезаписью; очистка без `Tmp`.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- ASSUMPTION(OQ-79): ИС Сканирование не берёт файл, пока шлюз держит его открытым на запись (`smbclient.open_file`
|
||||||
|
по умолчанию открывает монопольно), — так работал старый АРМ. Если подтвердится обратное, вернуть запись через
|
||||||
|
временный файл.
|
||||||
|
- Учётной записи шары больше не нужны права на `Tmp` и на переименование; нужна запись в `Input`.
|
||||||
|
- Остатки `Tmp/.*.part` от прежних версий шлюз больше не трогает — убрать вручную.
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Передача файла в ИС Сканирование через горячие папки
|
||||||
|
После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как
|
||||||
|
`<job_id>.<ext>`, где `<ext>` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL
|
||||||
|
завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Файл SHALL записываться сразу во входящий каталог
|
||||||
|
открытием на запись с усечением, как в старом АРМ (решение владельца 09.10.2026): временных файлов, служебного каталога
|
||||||
|
и переименования нет; если `<Input>/<job_id>.<ext>` уже существует, он SHALL быть перезаписан содержимым объекта S3.
|
||||||
|
Затем сервис SHALL опрашивать исходящий каталог `<SCAN_OUTPUT_DIR>/<job_id>/` каждые `SCAN_POLL_SECONDS` до
|
||||||
|
`min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)` (`SCAN_TIMEOUT_SECONDS` по умолчанию 1200). ИС кладёт в этот каталог
|
||||||
|
все распознанные документы задания — по одному `*.json` на документ, набор появляется целиком и больше не меняется;
|
||||||
|
пустой или отсутствующий каталог означает ожидание. Ответ SHALL считаться готовым, когда в каталоге есть хотя бы один
|
||||||
|
`*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (по умолчанию 2); готовый набор SHALL
|
||||||
|
включать **все** `*.json` каталога по возрастанию имени, остальные файлы игнорируются.
|
||||||
|
|
||||||
|
#### Scenario: Файл положен во входящий каталог
|
||||||
|
- **WHEN** получен запрос для `aep_5.PDF` с `job_id = 3f1c…`
|
||||||
|
- **THEN** во входящем каталоге появляется `3f1c….pdf`, байт в байт равный объекту S3; каталог `Tmp/` не создаётся, временных файлов нет
|
||||||
|
|
||||||
|
#### Scenario: Назначение уже существует
|
||||||
|
- **WHEN** запрос для `aep_5.pdf` с `job_id = 3f1c…` обрабатывается, а `Input/3f1c….pdf` уже лежит с другим содержимым (задание повторено ядром после потери ack в другом процессе)
|
||||||
|
- **THEN** `Input/3f1c….pdf` перезаписан и байт в байт равен объекту S3, задание продолжается опросом `Output/3f1c…/`, конверт `RECOGNITION_FAILED` не публикуется
|
||||||
|
|
||||||
|
#### Scenario: Запрос без расширения
|
||||||
|
- **WHEN** получен запрос с `original_name = "scan"` без точки
|
||||||
|
- **THEN** опубликован конверт `error`, `error.code = RECOGNITION_FAILED`, сообщение о расширении; во входящем каталоге файла нет
|
||||||
|
|
||||||
|
#### Scenario: Ответ из одного файла
|
||||||
|
- **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS`
|
||||||
|
- **THEN** опрос прекращается не позднее чем через `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после первого опроса, увидевшего окончательный набор (отсчёт стабилизации — от этого опроса, а не от времени изменения файла), ответ из одного файла передаётся дальше по конвейеру
|
||||||
|
|
||||||
|
#### Scenario: Ответ из нескольких файлов
|
||||||
|
- **WHEN** в `Output/3f1c…/` лежат `3f1c….json` и `3f1c…_ofr.json`, посторонний файл `readme.txt`, и набор не меняется `SCAN_SETTLE_SECONDS`
|
||||||
|
- **THEN** дальше по конвейеру передаются оба `*.json` по возрастанию имени, `readme.txt` игнорируется
|
||||||
|
|
||||||
|
#### Scenario: Набор ещё пишется
|
||||||
|
- **WHEN** размер файла или число файлов в `Output/3f1c…/` менялось менее `SCAN_SETTLE_SECONDS` назад
|
||||||
|
- **THEN** ответ не считается готовым, опрос продолжается до дедлайна
|
||||||
|
|
||||||
|
#### Scenario: Ошибка посреди записи
|
||||||
|
- **WHEN** запись `Input/3f1c….pdf` прервалась ошибкой ввода-вывода
|
||||||
|
- **THEN** опубликован конверт `RECOGNITION_FAILED`; недописанный файл удалён только при `SCAN_CLEANUP=true`, при `false` остаётся на месте
|
||||||
|
|
||||||
|
### Requirement: Очистка горячей папки под флагом
|
||||||
|
При `SCAN_CLEANUP=true` после того, как готовый набор ответа прочитан и склеен в `raw/{job_id}.json` (независимо от
|
||||||
|
исхода нормализации), сервис SHALL удалить всё содержимое `<SCAN_OUTPUT_DIR>/<job_id>/` (json-файлы ответа и любые
|
||||||
|
прочие) и сам каталог `<job_id>`, входящий файл `<Input>/<job_id>.<ext>` и его вариант `<Input>/<job_id>.<ext>.done`,
|
||||||
|
если они существуют. При `SCAN_CLEANUP=false` (по умолчанию) сервис MUST NOT удалять ничего, включая недописанный при
|
||||||
|
ошибке записи входящий файл. При `TIMEOUT`, `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи очистка MUST NOT выполняться
|
||||||
|
(при ошибке записи и `SCAN_CLEANUP=true` снимается только недописанный входящий файл). Ошибка удаления SHALL быть
|
||||||
|
записана в лог одной строкой на задание и MUST NOT менять конверт, `metrics.warnings` и `/healthcheck`.
|
||||||
|
|
||||||
|
#### Scenario: Очистка включена
|
||||||
|
- **WHEN** `SCAN_CLEANUP=true`, ответ на `3f1c….pdf` прочитан из `Output/3f1c…/`
|
||||||
|
- **THEN** после публикации конверта в горячей папке нет `Output/3f1c…/`, `Input/3f1c….pdf` и `Input/3f1c….pdf.done`
|
||||||
|
|
||||||
|
#### Scenario: Очистка выключена
|
||||||
|
- **WHEN** `SCAN_CLEANUP=false`, ответ прочитан
|
||||||
|
- **THEN** все файлы остаются на месте, сервис ничего не удаляет и не пишет в лог о очистке (решение №27)
|
||||||
|
|
||||||
|
#### Scenario: Нет прав на удаление
|
||||||
|
- **WHEN** `SCAN_CLEANUP=true`, удаление `Output/3f1c…/3f1c….json` завершается `PermissionError`
|
||||||
|
- **THEN** конверт опубликован с тем же статусом, что и без очистки; в логе одна строка об ошибке удаления; `/healthcheck.hotfolder = ok`
|
||||||
|
|
||||||
|
#### Scenario: Таймаут не чистит
|
||||||
|
- **WHEN** `SCAN_CLEANUP=true`, ответ не появился до дедлайна
|
||||||
|
- **THEN** опубликован `TIMEOUT`, входящий файл и каталог `Output/<job_id>/` (если есть) не тронуты
|
||||||
18
openspec/changes/hotfolder-direct-write/tasks.md
Normal file
18
openspec/changes/hotfolder-direct-write/tasks.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
## 1. Тесты (scan-gateway, `api/tests/`)
|
||||||
|
|
||||||
|
- [x] 1.1 `unit/test_smb_hotfolder.py`: `put` пишет `open_file(<Input>\<имя>, "wb")` без `Tmp`/`rename`; существующий
|
||||||
|
файл перезаписывается; ошибка записи — снятие файла только при `cleanup=True`; `cleanup` не трогает `.part`
|
||||||
|
- [x] 1.2 `unit/test_local_hotfolder.py`: то же для `LocalHotFolder`; `Tmp` не создаётся `put`
|
||||||
|
- [x] 1.3 `unit/test_config.py`, `unit/test_hotfolder_factory.py`: нет `SCAN_TMP_DIR` у шлюза, `SCAN_TIMEOUT_SECONDS`
|
||||||
|
по умолчанию 1200; интеграционные `test_roundtrip.py` — без ожиданий про `Tmp/.part`
|
||||||
|
|
||||||
|
## 2. Реализация (scan-gateway, `api/src/`)
|
||||||
|
|
||||||
|
- [x] 2.1 `gateway/smb.py`: `put` — прямая запись (D-1, D-2, D-5), убрать `tmp_dir`, `.part` из `cleanup`
|
||||||
|
- [x] 2.2 `gateway/hotfolder.py`: `LocalHotFolder.put` — прямая запись; `tmp_dir` только для эмулятора (D-3)
|
||||||
|
- [x] 2.3 `core/config.py`, `gateway/__init__.py`: убрать `SCAN_TMP_DIR` у шлюза; `SCAN_TIMEOUT_SECONDS = 1200`
|
||||||
|
|
||||||
|
## 3. Документация
|
||||||
|
|
||||||
|
- [x] 3.1 `README.md`, `CLAUDE.md`, `api/.env.example` — прямая запись, `SCAN_TMP_DIR` только у эмулятора, таймаут 1200
|
||||||
|
- [x] 3.2 `make check-full`
|
||||||
Loading…
x
Reference in New Issue
Block a user