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>`
|
||||
(расширение `original_name` в нижнем регистре; `.part` пишется в `SCAN_TMP_DIR`, по умолчанию `Tmp`, на той же ФС/шаре и
|
||||
публикуется **без замены**), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый
|
||||
(расширение `original_name` в нижнем регистре; пишется **сразу** открытием `wb`, существующий файл перезаписывается — как в
|
||||
старом АРМ), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый
|
||||
распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить
|
||||
горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json`
|
||||
(`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.
|
||||
- Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть
|
||||
в fixtures и объяснять; эталоны меняются отдельным PR fixtures.
|
||||
- Горячие папки (раскладка FileWorker): имя `<job_id>.<ext>`, `.part` только в `Tmp`, запись без замены (`os.link` /
|
||||
`smbclient.rename`; существующее назначение = «доставлено»). `.part`, ставший целью публикации, снимается самой записью
|
||||
независимо от флага (№26); любое другое удаление (ответ, входящий файл, `.done`, осиротевшие `.part`) — только при
|
||||
- Горячие папки (раскладка FileWorker): имя `<job_id>.<ext>`, запись сразу в `Input` открытием `wb` с перезаписью, без
|
||||
временного файла и `Tmp` — как в старом АРМ (change `hotfolder-direct-write`, ASSUMPTION(OQ-79)); `Tmp` — только у
|
||||
эмулятора. Любое удаление (ответ, входящий файл, `.done`, недописанный при ошибке записи входящий файл) — только при
|
||||
`SCAN_CLEANUP=true` и только после сохранения `raw/` (не при `TIMEOUT`, `FILE_READ_ERROR`, ошибке записи во входящий каталог или `raw/`). Ошибка
|
||||
удаления — одна строка лога, конверт не меняется. Нет расширения у `original_name` → `RECOGNITION_FAILED`.
|
||||
- Набор ответа готов по 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 и до ожидания слота)
|
||||
3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
|
||||
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 готов, когда
|
||||
не менялся SCAN_SETTLE_SECONDS
|
||||
7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации)
|
||||
@ -60,19 +60,15 @@ ack (с `attempt` повторного запроса); файл в ИС вто
|
||||
`original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII
|
||||
(`aep_5.PDF` → `<job_id>.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не
|
||||
попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку.
|
||||
- Запись атомарна и идёт **без замены**: сначала временный файл `.<job_id>.<ext>.part` в служебном каталоге
|
||||
`<SCAN_TMP_DIR>` (по умолчанию `Tmp`), затем он публикуется в `Input` под настоящим именем. `Tmp` лежит рядом с `Input`
|
||||
и `Output`, на той же ФС/шаре: публикация атомарна, а ИС не может забрать недописанный файл (временных файлов в
|
||||
`Input` нет — решение владельца 06.10.2026, у FileWorker `.tmp` лежит рядом с назначением). В режиме `local` публикация —
|
||||
жёсткая ссылка `.part` → `Input/<имя>` и удаление `.part`; на файловой системе без жёстких ссылок
|
||||
(`EPERM`/`EXDEV`/`ENOTSUP`/`EOPNOTSUPP` от самой ссылки) — `os.replace`, если назначения ещё нет (решение №17).
|
||||
`.part` создаётся заново: остаток прежней записи удаляется (ошибка удаления подавляется), файл открывается с `O_EXCL`;
|
||||
поверх оставшегося `.part` пишем только если это не жёсткая ссылка (`st_nlink == 1`) — иначе исключение (решение №32),
|
||||
чтобы не испортить уже опубликованный файл. В режиме `smb` — `smbclient.rename` (поднимает `FileExistsError`, если
|
||||
назначение есть). Если `Input/<job_id>.<ext>` уже существует (повторная публикация задания после потери ack в другом
|
||||
процессе), файл считается доставленным: ничего не пишется, назначение не перезаписывается, `.part` не трогается (при
|
||||
`SCAN_CLEANUP=true` существующий `.part` снимается), задание продолжается опросом `Output/<job_id>/` (решение №35). Каталоги `Input` и `Tmp`
|
||||
создаются при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`.
|
||||
- Запись — **сразу в `Input`**, как в старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`):
|
||||
файл открывается на запись с усечением (`wb`) и пишется целиком; временного файла, служебного каталога и
|
||||
переименования нет. В режиме `smb` — `smbclient.open_file(<Input>\<имя>, "wb")`: файл открыт монопольно, пока идёт
|
||||
запись, и ИС его не прочитает (ASSUMPTION(OQ-79) — так работал старый АРМ). Если `Input/<job_id>.<ext>` уже существует
|
||||
(повтор задания ядром после потери ack в другом процессе), он **перезаписывается** содержимым объекта S3, задание
|
||||
продолжается опросом `Output/<job_id>/`. Ошибка посреди записи → `RECOGNITION_FAILED`; недописанный файл снимается
|
||||
только при `SCAN_CLEANUP=true` (иначе остаётся в `Input`). Каталог `Input` создаётся при необходимости (без
|
||||
родителей; отказ в правах при существующем каталоге — не ошибка), корень горячей папки сам не создаётся: нет корня —
|
||||
`RECOGNITION_FAILED`.
|
||||
- Ответ ИС — каталог `<SCAN_OUTPUT_DIR>/<job_id>/`: ИС кладёт туда **все** распознанные документы задания одновременно,
|
||||
по одному `*.json` на файл (`Output/<job_id>/*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один
|
||||
ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть
|
||||
@ -96,24 +92,19 @@ ack (с `attempt` повторного запроса); файл в ИС вто
|
||||
- **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в
|
||||
`raw/{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` удаляются файлы каталога, а
|
||||
подкаталог внутри `Output/<job_id>/` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки
|
||||
поднимается и логируется, остальные пункты (входящий файл, `.done`, `.part`) в этом задании не удаляются.
|
||||
поднимается и логируется, остальные пункты (входящий файл, `.done`) в этом задании не удаляются.
|
||||
- **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во
|
||||
входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются.
|
||||
- **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт,
|
||||
`metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа.
|
||||
- **`.part` и флаг (решение №26).** `.part`, ставший целью успешной публикации, снимается **самой записью**, независимо
|
||||
от `SCAN_CLEANUP` (в `local` — удаление после жёсткой ссылки, ошибка удаления подавляется; в `smb` `.part`
|
||||
переименовывается во входящий и отдельного удаления не требует): иначе при выключенной очистке на каждое задание
|
||||
оставалась бы лишняя ссылка, а ошибка удаления на шаре с неподтверждёнными правами превращала бы доставленный файл в
|
||||
`RECOGNITION_FAILED`. **Флаг управляет остатками:** осиротевшие `.part` (оборванная запись, `.part` при уже
|
||||
существующем назначении), каталог ответа, входящий файл и `.done`. При `SCAN_CLEANUP=false` сервис не удаляет ничего из
|
||||
этого и не пишет в лог об осиротевших `.part` (решение №27): они накапливаются в `Tmp` и убираются вручную или
|
||||
включением флага.
|
||||
- Права на SMB: для очистки учётной записи шары нужны права на удаление в `Input`, `Output` (включая каталог
|
||||
`<job_id>`) и `Tmp`; для записи — создание файлов в `Tmp` и `Input` и переименование. Пока права на удаление не
|
||||
- **Флаг управляет всем удалением:** при `SCAN_CLEANUP=false` сервис не удаляет ничего — ни каталог ответа, ни входящий
|
||||
файл и `.done`, ни недописанный при ошибке записи входящий файл — и не пишет об этом в лог (решение №27). Остатки
|
||||
`Tmp/.*.part` от версий шлюза до change `hotfolder-direct-write` сервис не трогает — убрать вручную.
|
||||
- Права на SMB: для записи учётной записи шары нужно создание и перезапись файлов в `Input`, для чтения ответа —
|
||||
листинг и чтение `Output`; для очистки — удаление в `Input` и `Output` (включая каталог `<job_id>`). Пока права на удаление не
|
||||
подтверждены, оставляйте `SCAN_CLEANUP=false`.
|
||||
|
||||
### Эмулятор ИС в режиме `local`
|
||||
@ -315,12 +306,11 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
|
||||
| `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) |
|
||||
| `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня |
|
||||
| `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня |
|
||||
| `SCAN_TMP_DIR` | `Tmp` | служебный каталог относительно корня (на той же ФС/шаре, что `Input`/`Output`): временный `.<job_id>.<ext>.part` записи входящего файла |
|
||||
| `SCAN_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды |
|
||||
| `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_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/`); битый справочник — сервис не стартует |
|
||||
| `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте |
|
||||
| `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) |
|
||||
@ -335,7 +325,8 @@ MinIO и NATS); те же значения подхватываются из `EN
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|---|---|---|
|
||||
| `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_EMULATOR_DEFAULT` | пусто | основа имени набора (например, `aep_6`), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка `INVALID_DOCUMENT` |
|
||||
| `SCAN_EMULATOR_DELAY` | `2` | задержка перед выдачей ответа, секунды (>= 0) |
|
||||
@ -508,8 +499,9 @@ make golden-update # пересобрать эталоны нормализ
|
||||
- Недописанный набор ответа ИС: защита — settle (`SCAN_SETTLE_SECONDS`), а не разбор содержимого. Если ИС дописывает
|
||||
файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается:
|
||||
`response = null`, `parse_error` в `raw/`, ошибка этого файла (`INVALID_FORMAT`) — в `metrics.warnings`.
|
||||
- Запись без замены на файловой системе без жёстких ссылок (`EPERM`/`EXDEV`/`ENOTSUP`): между проверкой назначения и
|
||||
`os.replace` есть крошечное окно гонки (повтор задания с тем же `job_id` редок, решение №17).
|
||||
- Запись сразу в `Input` (change `hotfolder-direct-write`): защита от чтения недописанного файла — только монопольное
|
||||
открытие `smbclient` на время записи (ASSUMPTION(OQ-79), как в старом АРМ). Если ИС всё же возьмёт файл во время
|
||||
записи, распознавание даст ошибку; откат — вернуть запись через временный файл.
|
||||
- Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе
|
||||
он кладёт ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог. Индекс хешей
|
||||
пересчитывается при изменении списка файлов каталога фикстур: изменённый, но не переименованный PDF не
|
||||
|
||||
@ -43,19 +43,17 @@ SCAN_HOTFOLDER_PATH=./.hotfolder
|
||||
# Подкаталоги корня: «Входящие» (сюда кладётся UUID_имя) и «Исходящие» (оттуда забираются все *.json из <UUID>/)
|
||||
SCAN_INPUT_DIR=Input
|
||||
SCAN_OUTPUT_DIR=Output
|
||||
# Служебный каталог корня: временный .part пишется сюда и переименовывается во «Входящие» (та же ФС/шара — rename
|
||||
# атомарен, во «Входящих» не бывает недописанных файлов); создаётся при первой записи
|
||||
SCAN_TMP_DIR=Tmp
|
||||
# Период опроса «Исходящих», секунды
|
||||
SCAN_POLL_SECONDS=5
|
||||
# Набор *.json в <UUID>/ считается готовым, когда имена и размеры файлов не менялись столько секунд (>= 0)
|
||||
SCAN_SETTLE_SECONDS=2
|
||||
# Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса); повторной отправки нет
|
||||
SCAN_TIMEOUT_SECONDS=300
|
||||
# Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса; 20 мин — в паре со сроком
|
||||
# ответа OCR ядра); повторной отправки нет
|
||||
SCAN_TIMEOUT_SECONDS=1200
|
||||
# Сколько заданий обрабатывается одновременно
|
||||
SCAN_MAX_INFLIGHT=4
|
||||
# Очистка горячей папки после прочтения ответа (D-2): true — удалить всё содержимое Output/<job_id>/, входящий файл
|
||||
# (и его .done), осиротевший .part в Tmp; false (по умолчанию) — ничего не удалять
|
||||
# (и его .done); false (по умолчанию) — ничего не удалять
|
||||
SCAN_CLEANUP=false
|
||||
# Каталог справочников нормализатора OCR (statement_lines.yaml, ocr_lookalikes.yaml, statement_labels.yaml);
|
||||
# относительный путь — от каталога запуска (api/); битый справочник — сервис не стартует
|
||||
@ -70,7 +68,9 @@ SCAN_RULES_DIR=rules
|
||||
# --- Эмулятор ИС Сканирование (python -m src.scan_emulator, make emulator; только dev) ---
|
||||
# Эти параметры читает сам эмулятор, сервис шлюза их не использует. Корень горячей папки и подкаталоги
|
||||
# эмулятор берёт из 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)
|
||||
SCAN_FIXTURES_DIR=../../fixtures/ocr
|
||||
# Задержка перед выдачей ответа, секунды (имитация работы ИС)
|
||||
|
||||
@ -83,13 +83,13 @@ class Settings(BaseSettings):
|
||||
SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать)
|
||||
SCAN_INPUT_DIR: str = "Input"
|
||||
SCAN_OUTPUT_DIR: str = "Output"
|
||||
SCAN_TMP_DIR: str = "Tmp" # служебный каталог временных файлов записи (рядом с Input, на той же ФС/шаре)
|
||||
SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих»
|
||||
# набор *.json ответа готов, когда имена и размеры не менялись столько секунд; 0 — готов сразу (тесты)
|
||||
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 # одновременно обрабатываемых заданий
|
||||
# Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true.
|
||||
# Очистка горячей папки после прочтения набора ответа (D-2): ответ, входящий файл, .done — только при true.
|
||||
# На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
|
||||
SCAN_CLEANUP: bool = False
|
||||
SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/)
|
||||
|
||||
@ -18,7 +18,6 @@ def create_hotfolder(settings: Settings) -> HotFolder:
|
||||
settings.SCAN_HOTFOLDER_PATH,
|
||||
settings.SCAN_INPUT_DIR,
|
||||
settings.SCAN_OUTPUT_DIR,
|
||||
settings.SCAN_TMP_DIR,
|
||||
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
||||
cleanup=settings.SCAN_CLEANUP,
|
||||
)
|
||||
@ -31,7 +30,6 @@ def create_hotfolder(settings: Settings) -> HotFolder:
|
||||
smb.SMB_PATH,
|
||||
settings.SCAN_INPUT_DIR,
|
||||
settings.SCAN_OUTPUT_DIR,
|
||||
settings.SCAN_TMP_DIR,
|
||||
login=smb.SMB_LOGIN,
|
||||
password=smb.SMB_PASSWORD,
|
||||
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, D-2, R-4, R-5).
|
||||
|
||||
Файл кладётся во входящий каталог как `<job_id>.<ext>` (D-1): сначала временный `.<имя>.part` в служебном каталоге
|
||||
`Tmp` (на той же ФС/шаре), затем жёсткая ссылка (без замены) во входящий (решение №17). Ответ ИС — набор файлов каталога
|
||||
Файл кладётся во входящий каталог как `<job_id>.<ext>` (D-1) сразу, открытием `wb` с перезаписью существующего — как в
|
||||
старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`). Ответ ИС — набор файлов каталога
|
||||
`<output>/<job_id>/`: ИС кладёт туда все распознанные документы задания, по одному `*.json` на документ (OQ-78).
|
||||
`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 errno
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@ -29,8 +28,6 @@ _ILLEGAL_CHARS = frozenset(':<>"|?*') | frozenset(chr(code) for code in range(0x
|
||||
_FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS)
|
||||
# Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z]
|
||||
_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:
|
||||
@ -171,7 +168,8 @@ class LocalHotFolder:
|
||||
"""Горячая папка в обычном каталоге: dev, compose-том, unit-тесты.
|
||||
|
||||
`root`, `input_dir`, `output_dir`, `tmp_dir`, `answer_dir` и `answer_path` открыты намеренно (R-4): эмулятор
|
||||
ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа).
|
||||
ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа);
|
||||
`tmp_dir` нужен только эмулятору (сборка набора ответа), `put` его не использует.
|
||||
`settle_seconds` и `clock` (монотонные часы) — готовность набора ответа (`SettleTracker`).
|
||||
`cleanup_enabled` (D-2) — очистка включена при `SCAN_CLEANUP`.
|
||||
"""
|
||||
@ -204,76 +202,25 @@ class LocalHotFolder:
|
||||
return self.answer_dir(job_id) / f"{job_id}.json"
|
||||
|
||||
def put(self, name: str, data: bytes) -> None:
|
||||
"""Атомарная запись без замены: `.<name>.part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/<name>` и
|
||||
удаление `.part` как часть успешной публикации (R-8). Назначение уже есть — файл считается доставленным
|
||||
(повтор задания, R-17): ничего не пишется, `.part` снимается только при включённой очистке. Корень не
|
||||
создаётся (нет корня — `FileNotFoundError`)."""
|
||||
"""Записать `input_dir/<name>` сразу (`wb`, перезапись существующего, как в старом АРМ — решение владельца
|
||||
09.10.2026). `Input` создаётся без родителей, корень — нет (нет корня — `FileNotFoundError`). При ошибке записи
|
||||
недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется. Ошибка самого
|
||||
открытия ничего не удаляет: на месте может лежать целый файл прошлой доставки (I-1)."""
|
||||
validate_name(name)
|
||||
self.tmp_dir.mkdir(exist_ok=True)
|
||||
self.input_dir.mkdir(exist_ok=True)
|
||||
part = self.tmp_dir / f".{name}.part"
|
||||
target = self.input_dir / name
|
||||
if target.exists():
|
||||
# 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)
|
||||
handle = open(target, "wb") # noqa: SIM115 — открытие вне try: его ошибка не снимает чужой файл (I-1)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
with handle:
|
||||
handle.write(data)
|
||||
except BaseException:
|
||||
self._drop_part(part)
|
||||
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)
|
||||
self._drop_partial(target)
|
||||
raise
|
||||
|
||||
def _drop_part(self, part: Path) -> None:
|
||||
def _drop_partial(self, path: Path) -> None:
|
||||
if self.cleanup_enabled:
|
||||
with contextlib.suppress(OSError):
|
||||
part.unlink(missing_ok=True)
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def cleanup(self, job_id: str, name: str) -> bool:
|
||||
if not self.cleanup_enabled:
|
||||
@ -282,7 +229,7 @@ class LocalHotFolder:
|
||||
folder = self.answer_dir(job_id)
|
||||
if folder.is_dir():
|
||||
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:
|
||||
os.unlink(path)
|
||||
except FileNotFoundError:
|
||||
|
||||
@ -4,10 +4,10 @@ r"""Горячая папка на SMB-шаре платформы (`SCAN_HOTFOL
|
||||
в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы
|
||||
сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения).
|
||||
|
||||
Пути — UNC: временный файл `\\<host>\<SMB_PATH>\<tmp_dir>\.<имя>.part` переименовывается (rename без замены)
|
||||
во входящий `\\<host>\<SMB_PATH>\<input_dir>\<имя>` (одна шара — rename атомарен, OQ-79); ответ — все `*.json`
|
||||
каталога `\\<host>\<SMB_PATH>\<output_dir>\<job_id>\` (OQ-78), готовность — по стабилизации набора
|
||||
(`SettleTracker`). Очистка (D-2) удаляет ответы, входящий файл и временные файлы при `SCAN_CLEANUP=true`.
|
||||
Пути — UNC: входящий файл пишется сразу в `\\<host>\<SMB_PATH>\<input_dir>\<имя>` (`wb`, существующий перезаписывается,
|
||||
как в старом АРМ — решение владельца 09.10.2026, change `hotfolder-direct-write`); ответ — все `*.json` каталога
|
||||
`\\<host>\<SMB_PATH>\<output_dir>\<job_id>\` (OQ-78), готовность — по стабилизации набора (`SettleTracker`).
|
||||
Очистка (D-2) удаляет ответы, входящий файл и его `.done` при `SCAN_CLEANUP=true`.
|
||||
Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся
|
||||
по `errno == ENOENT`.
|
||||
"""
|
||||
@ -66,9 +66,9 @@ def build_unc_root(host: str, path: str) -> str:
|
||||
|
||||
class SmbHotFolder:
|
||||
"""Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у
|
||||
`LocalHotFolder`: те же правила имён, атомарная запись `Tmp\\.<name>.part` + rename без замены в `Input`,
|
||||
`Tmp` и `Input` создаются без родителей, корень не создаётся, готовность набора ответа — по стабилизации
|
||||
(`SettleTracker`). Очистка (D-2) под флагом `SCAN_CLEANUP`."""
|
||||
`LocalHotFolder`: те же правила имён, запись сразу в `Input` с перезаписью, `Input` создаётся без родителей, корень
|
||||
не создаётся, готовность набора ответа — по стабилизации (`SettleTracker`). Очистка (D-2) под флагом
|
||||
`SCAN_CLEANUP`."""
|
||||
|
||||
def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов и готовность, как у LocalHotFolder
|
||||
self,
|
||||
@ -76,7 +76,6 @@ class SmbHotFolder:
|
||||
path: str,
|
||||
input_dir: str = "Input",
|
||||
output_dir: str = "Output",
|
||||
tmp_dir: str = "Tmp",
|
||||
*,
|
||||
login: str = "",
|
||||
password: str = "",
|
||||
@ -87,7 +86,6 @@ class SmbHotFolder:
|
||||
self.root: str = build_unc_root(host, path)
|
||||
self.input_dir: str = ntpath.join(self.root, input_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._settle = SettleTracker(settle_seconds, clock)
|
||||
self.cleanup_enabled = cleanup
|
||||
@ -102,29 +100,22 @@ class SmbHotFolder:
|
||||
return ntpath.join(self.answer_dir(job_id), f"{job_id}.json")
|
||||
|
||||
def put(self, name: str, data: bytes) -> None:
|
||||
"""Записать `.<name>.part` в служебный каталог, затем `smbclient.rename` в `<input>\\<name>` без замены.
|
||||
Имя проверяется до любого обращения к шаре. Назначение уже есть (повтор задания) — это не ошибка: файл
|
||||
считается доставленным и не заменяется. В этом случае, как и при любой другой ошибке записи или переименования,
|
||||
`.part` снимается только при включённой очистке (`SCAN_CLEANUP`, D-2), иначе остаётся в `Tmp`; ошибка снятия
|
||||
исходную не маскирует. При успехе `.part` забирает сам rename. Нет корня — `SMBOSError` (ENOENT) от первого
|
||||
`mkdir`.
|
||||
"""Записать `<input>\\<name>` сразу, открытием `wb` (как в старом АРМ, решение владельца 09.10.2026): временного
|
||||
файла и rename нет, существующий файл перезаписывается. `smbclient.open_file` открывает файл монопольно — пока
|
||||
запись идёт, ИС его не прочитает (ASSUMPTION(OQ-79)). Имя проверяется до любого обращения к шаре. При ошибке
|
||||
записи недописанный файл снимается только при включённой очистке (`SCAN_CLEANUP`, R-3); ошибка снятия исходную
|
||||
не маскирует. Ошибка самого открытия (файл занят ИС, нет прав) ничего не удаляет: недописанного файла нет, а на
|
||||
месте может лежать целый файл прошлой доставки (I-1). Нет корня — `SMBOSError` (ENOENT) от `mkdir`.
|
||||
"""
|
||||
validate_name(name)
|
||||
self._ensure_dir(self.tmp_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:
|
||||
with smbclient.open_file(part, mode="wb", **self._credentials) as fd:
|
||||
with fd:
|
||||
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:
|
||||
self._drop_part(part)
|
||||
self._drop_partial(target)
|
||||
raise
|
||||
|
||||
def poll(self, job_id: str) -> list[AnswerFile] | None:
|
||||
@ -166,14 +157,14 @@ class SmbHotFolder:
|
||||
"""Число заданий с ненастоявшимся набором (для тестов и диагностики)."""
|
||||
return len(self._settle)
|
||||
|
||||
def _drop_part(self, path: str) -> None:
|
||||
"""Удалить временный файл при ошибке, только если очистка включена (D-2)."""
|
||||
def _drop_partial(self, path: str) -> None:
|
||||
"""Удалить недописанный входящий файл при ошибке записи, только если очистка включена (D-2, R-3)."""
|
||||
if self.cleanup_enabled:
|
||||
with contextlib.suppress(Exception):
|
||||
smbclient.remove(path, **self._credentials)
|
||||
|
||||
def cleanup(self, job_id: str, name: str) -> bool:
|
||||
"""Удалить следы задания: ответ из `output`, входящий файл, временные файлы. Возвращает `False`, если
|
||||
"""Удалить следы задания: ответ из `output`, входящий файл и его `.done`. Возвращает `False`, если
|
||||
очистка выключена; `True` при успешной очистке. Ошибки удаления (кроме ENOENT) поднимаются."""
|
||||
if not self.cleanup_enabled:
|
||||
return False
|
||||
@ -194,7 +185,6 @@ class SmbHotFolder:
|
||||
for path in (
|
||||
ntpath.join(self.input_dir, name),
|
||||
ntpath.join(self.input_dir, f"{name}.done"),
|
||||
ntpath.join(self.tmp_dir, f".{name}.part"),
|
||||
):
|
||||
try:
|
||||
smbclient.remove(path, **self._credentials)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и
|
||||
эмулятор ИС Сканирование (`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-каталог.
|
||||
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, `.part` живёт в `Tmp`), пару фикстур
|
||||
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, пишется прямо в `Input`), пару фикстур
|
||||
эмулятор находит по sha256 содержимого: в его каталоге фикстур лежат PDF и ответы (реальные `aep_*` из
|
||||
`fixtures/ocr` и синтетический `ok_doc`).
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`);
|
||||
4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`;
|
||||
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`; файл результата
|
||||
из MinIO — схемой `recognition-result`; склеенный сырой ответ в MinIO содержит ровно те файлы, что лежат в каталоге
|
||||
ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора
|
||||
@ -178,10 +178,6 @@ class Roundtrip:
|
||||
def input_dir(self) -> Path:
|
||||
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:
|
||||
return self.root / self.settings.SCAN_OUTPUT_DIR / job_id
|
||||
|
||||
@ -260,12 +256,6 @@ class Roundtrip:
|
||||
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))
|
||||
|
||||
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]:
|
||||
"""Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог."""
|
||||
body = await self.request(original_name, data)
|
||||
@ -283,8 +273,7 @@ class Roundtrip:
|
||||
"""Сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий файл `<job_id>.pdf` = объект S3.
|
||||
|
||||
Без очистки (`SCAN_CLEANUP=false`) каталог ответа и входящий файл (с суффиксом `.done` эмулятора) остаются; при
|
||||
очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output/<job_id>/` нет. `.part` в `Tmp` нет
|
||||
в обоих режимах.
|
||||
очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output/<job_id>/` нет.
|
||||
"""
|
||||
job_id = body["job_id"]
|
||||
assert result.body["file_id"] == body["file_id"]
|
||||
@ -297,7 +286,6 @@ class Roundtrip:
|
||||
assert entry["response"]["file_uid"] == job_id
|
||||
stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
|
||||
assert stat.content_type == JSON_CONTENT_TYPE
|
||||
assert self.stray_parts() == []
|
||||
if cleanup:
|
||||
assert list(self.input_dir.iterdir()) == []
|
||||
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,
|
||||
input_dir=settings.SCAN_INPUT_DIR,
|
||||
output_dir=settings.SCAN_OUTPUT_DIR,
|
||||
tmp_dir=settings.SCAN_TMP_DIR,
|
||||
tmp_dir="Tmp", # служебный каталог эмулятора; у шлюза его нет (change hotfolder-direct-write)
|
||||
fixtures_dir=fixtures,
|
||||
delay=EMULATOR_DELAY,
|
||||
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 list(roundtrip_cleanup.input_dir.iterdir()) == []
|
||||
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,
|
||||
settings.SCAN_INPUT_DIR,
|
||||
settings.SCAN_OUTPUT_DIR,
|
||||
settings.SCAN_TMP_DIR,
|
||||
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
||||
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_INPUT_DIR == "Input"
|
||||
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_POLL_SECONDS == 5
|
||||
assert s.SCAN_TIMEOUT_SECONDS == 300
|
||||
assert s.SCAN_TIMEOUT_SECONDS == 1200
|
||||
assert s.SCAN_MAX_INFLIGHT == 4
|
||||
assert s.OPENBAO.SMB.SMB_HOST == ""
|
||||
assert s.OPENBAO.NATS.NATS_SEED == ""
|
||||
@ -80,6 +80,16 @@ def test_negative_settle_seconds_rejected(monkeypatch):
|
||||
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"])
|
||||
def test_settle_not_less_than_timeout_rejected(monkeypatch, settle):
|
||||
"""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_POLL_SECONDS == 0.1
|
||||
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.OPENBAO.MINIO.MINIO_URL == "127.0.0.1:9000"
|
||||
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_INPUT_DIR == "Input"
|
||||
assert s.SCAN_TMP_DIR == "Tmp"
|
||||
assert not hasattr(s, "SCAN_TMP_DIR")
|
||||
assert s.SCAN_SETTLE_SECONDS == 2
|
||||
assert s.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka"
|
||||
assert s.OPENBAO.NATS.NATS_JWT == ""
|
||||
|
||||
@ -20,7 +20,6 @@ def _local_settings(root: Path) -> Settings:
|
||||
"SCAN_HOTFOLDER_MODE": "local",
|
||||
"SCAN_HOTFOLDER_PATH": str(root),
|
||||
"SCAN_INPUT_DIR": "In",
|
||||
"SCAN_TMP_DIR": "Служебный",
|
||||
"SCAN_SETTLE_SECONDS": 1.5,
|
||||
}
|
||||
)
|
||||
@ -33,7 +32,6 @@ def _smb_settings() -> Settings:
|
||||
update={
|
||||
"SCAN_HOTFOLDER_MODE": "smb",
|
||||
"SCAN_OUTPUT_DIR": "Out",
|
||||
"SCAN_TMP_DIR": "T",
|
||||
"SCAN_SETTLE_SECONDS": 3,
|
||||
"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.input_dir == tmp_path / "In"
|
||||
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.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.input_dir == "\\\\smb.example.local\\share\\scan\\Input"
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton).
|
||||
|
||||
Быстрые тесты на `tmp_path`: атомарная запись входящего файла через служебный каталог `Tmp`, опрос набора ответов ИС
|
||||
Быстрые тесты на `tmp_path`: запись входящего файла прямо в `Input` с перезаписью, опрос набора ответов ИС
|
||||
с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён.
|
||||
"""
|
||||
|
||||
@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from src.gateway import hotfolder as hotfolder_module
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
def test_job_id_and_lowercase_extension(self) -> None:
|
||||
job_id = _job_id()
|
||||
@ -57,34 +83,12 @@ class TestInboxName:
|
||||
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:
|
||||
def _fill(self, hf: LocalHotFolder, job_id: str) -> None:
|
||||
hf.put(f"{job_id}.pdf", b"x")
|
||||
(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"{}", name=f"{job_id}_ofr.json")
|
||||
(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 not (hf.input_dir / f"{job_id}.pdf").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 hf.cleanup(job_id, f"{job_id}.pdf") is True # повтор — не ошибка
|
||||
|
||||
@ -181,60 +185,57 @@ class TestPut:
|
||||
hf.put(name, 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)
|
||||
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")
|
||||
assert hf.input_dir.is_dir()
|
||||
assert hf.tmp_dir.is_dir()
|
||||
assert not hf.output_dir.exists()
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == ["Input"] # Tmp не создаётся
|
||||
|
||||
def test_existing_tmp_dir_is_fine(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "Tmp").mkdir()
|
||||
LocalHotFolder(tmp_path).put("a.pdf", b"1")
|
||||
assert (tmp_path / "Input" / "a.pdf").read_bytes() == b"1"
|
||||
def test_existing_file_is_overwritten(self, tmp_path: Path) -> None:
|
||||
"""Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл."""
|
||||
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_custom_tmp_dir_name(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path, tmp_dir="Служебный")
|
||||
hf.put("a.pdf", b"1")
|
||||
assert (tmp_path / "Служебный").is_dir()
|
||||
assert not (tmp_path / "Tmp").exists()
|
||||
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(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:
|
||||
(tmp_path / "Input").mkdir()
|
||||
@ -285,227 +286,6 @@ class TestPut:
|
||||
hf.put(name, b"x")
|
||||
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:
|
||||
"""`poll` — все `*.json` каталога `<output>/<job_id>/` по имени, когда набор настоялся `settle_seconds`."""
|
||||
|
||||
@ -21,7 +21,6 @@ HOST = "smb.example.local"
|
||||
ROOT = "\\\\smb.example.local\\share\\scan"
|
||||
INPUT = ROOT + "\\Input"
|
||||
OUTPUT = ROOT + "\\Output"
|
||||
TMP = ROOT + "\\Tmp"
|
||||
|
||||
|
||||
class FakeClock:
|
||||
@ -171,6 +170,26 @@ def _write_answer(share_root: Path, job_id: str, data: bytes, name: str | None =
|
||||
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:
|
||||
def test_default_layout(self) -> None:
|
||||
hf = _hotfolder()
|
||||
@ -178,7 +197,6 @@ class TestPaths:
|
||||
assert hf.root == ROOT
|
||||
assert hf.input_dir == INPUT
|
||||
assert hf.output_dir == OUTPUT
|
||||
assert hf.tmp_dir == TMP
|
||||
assert hf.answer_dir(job_id) == f"{OUTPUT}\\{job_id}"
|
||||
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"
|
||||
|
||||
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.output_dir == ROOT + "\\Исходящие"
|
||||
assert hf.tmp_dir == ROOT + "\\Служебный"
|
||||
assert hf.answer_path("abc") == ROOT + "\\Исходящие\\abc\\abc.json"
|
||||
|
||||
@pytest.mark.parametrize(("host", "path"), [(HOST, ""), (HOST, "/"), ("", "share/scan"), ("", "\\\\srv")])
|
||||
@ -226,63 +243,13 @@ class TestPaths:
|
||||
|
||||
|
||||
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")
|
||||
data = bytes(range(256)) * 10
|
||||
_hotfolder().put(name, data)
|
||||
part = f"{TMP}\\.{name}.part"
|
||||
assert fake.calls == [
|
||||
("mkdir", (TMP,), {}),
|
||||
("mkdir", (INPUT,), {}),
|
||||
("open_file", (part, "wb"), {}),
|
||||
("rename", (part, f"{INPUT}\\{name}"), {}),
|
||||
]
|
||||
assert fake.calls == [("mkdir", (INPUT,), {}), ("open_file", (f"{INPUT}\\{name}", "wb"), {})]
|
||||
assert (share_root / "Input" / name).read_bytes() == data
|
||||
assert [p.name for p in (share_root / "Input").iterdir()] == [name]
|
||||
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"]
|
||||
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()
|
||||
@ -298,41 +265,29 @@ class TestPut:
|
||||
|
||||
def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||
"""Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно."""
|
||||
(share_root / "Tmp").mkdir()
|
||||
(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", "mkdir", "isdir", "open_file", "rename"]
|
||||
assert fake.calls[3] == ("isdir", (INPUT,), {})
|
||||
assert fake.funcs() == ["mkdir", "isdir", "open_file"]
|
||||
|
||||
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)
|
||||
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", "mkdir", "isdir"]
|
||||
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, TMP)
|
||||
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||
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:
|
||||
_hotfolder().put("a.pdf", b"1")
|
||||
assert excinfo.value is denied
|
||||
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:
|
||||
_hotfolder().put("empty.pdf", b"")
|
||||
assert (share_root / "Input" / "empty.pdf").read_bytes() == b""
|
||||
@ -371,58 +326,61 @@ class TestPut:
|
||||
_hotfolder().put(name, b"x")
|
||||
assert fake.calls == []
|
||||
|
||||
def test_cleans_part_file_on_rename_failure(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||
fake.fail["rename"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||
with pytest.raises(OSError):
|
||||
_hotfolder(cleanup=True).put("a.pdf", b"data")
|
||||
part = f"{TMP}\\.a.pdf.part"
|
||||
assert fake.calls[-1] == ("remove", (part,), {})
|
||||
assert list((share_root / "Input").iterdir()) == []
|
||||
assert list((share_root / "Tmp").iterdir()) == []
|
||||
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
|
||||
self, fake: FakeSmbClient, share_root: Path
|
||||
) -> None:
|
||||
_fail_write(fake)
|
||||
with pytest.raises(SMBOSError):
|
||||
_hotfolder().put("a.pdf", b"data")
|
||||
assert (share_root / "Input" / "a.pdf").read_bytes() == b"da"
|
||||
assert "remove" not in fake.funcs()
|
||||
|
||||
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||
fake.fail["rename"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||
fake.fail["remove"] = SMBException("соединение потеряно")
|
||||
def test_write_failure_removes_partial_file_when_cleanup_enabled(
|
||||
self, fake: FakeSmbClient, share_root: Path
|
||||
) -> None:
|
||||
_fail_write(fake)
|
||||
with pytest.raises(SMBOSError):
|
||||
_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:
|
||||
_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:
|
||||
def test_uses_rename_not_replace(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||
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"x")
|
||||
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"first, longer")
|
||||
hf.put("job.pdf", b"second")
|
||||
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first"
|
||||
assert fake._local(TMP + "\\.job.pdf.part").read_bytes() == b"second"
|
||||
|
||||
@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",), {})
|
||||
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"second"
|
||||
assert "rename" not in fake.funcs() and "replace" not in fake.funcs()
|
||||
|
||||
|
||||
class TestCleanup:
|
||||
@ -481,7 +439,7 @@ class TestCleanupDetails:
|
||||
hf.cleanup(_job_id(), name)
|
||||
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)
|
||||
job_id = _job_id()
|
||||
name = f"{job_id}.pdf"
|
||||
@ -490,14 +448,14 @@ class TestCleanupDetails:
|
||||
(root / folder).mkdir()
|
||||
(root / "Input" / name).write_bytes(b"pdf")
|
||||
(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.write_bytes(b"other job") # чужое задание не трогаем
|
||||
assert hf.cleanup(job_id, name) is True
|
||||
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"]
|
||||
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:
|
||||
hf = self._hf(fake)
|
||||
@ -523,7 +481,7 @@ class TestCleanupDetails:
|
||||
assert excinfo.value.ntstatus == NtStatus.STATUS_DIRECTORY_NOT_EMPTY
|
||||
assert not (answer / f"{job_id}.json").exists() # файлы сняты, каталог остался вместе с подкаталогом
|
||||
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:
|
||||
hf = self._hf(fake, login="user", password="secret")
|
||||
@ -536,13 +494,13 @@ class TestCleanupDetails:
|
||||
(answer / f"{job_id}_ofr.json").write_bytes(b"{}")
|
||||
fake.calls.clear()
|
||||
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)
|
||||
|
||||
def test_missing_answer_dir_and_files_are_fine(self, fake: FakeSmbClient) -> None:
|
||||
hf = self._hf(fake)
|
||||
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:
|
||||
hf = self._hf(fake)
|
||||
|
||||
@ -953,13 +953,14 @@ class TestSlice3a:
|
||||
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:
|
||||
"""Review Focus 4: входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT, файл не перезаписан."""
|
||||
"""Входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT; файл перезаписан объектом S3, как в старом
|
||||
АРМ (change hotfolder-direct-write, решение владельца 09.10.2026)."""
|
||||
env = await make_env()
|
||||
body = env.request(deadline_s=0.3)
|
||||
env.local.put(f"{body['job_id']}.pdf", b"already there")
|
||||
result = await env.run(body)
|
||||
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"]
|
||||
|
||||
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