wexample-wex-addon-process 3.0.1__tar.gz → 4.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/PKG-INFO +14 -6
  2. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/README.md +12 -4
  3. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/pyproject.toml +2 -2
  4. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/const/process.py +4 -0
  5. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/process_context.py +13 -0
  6. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/runner/process_runner.py +38 -18
  7. wexample_wex_addon_process-4.0.1/tests/unit/test_skip_and_failure.py +111 -0
  8. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/__init__.py +0 -0
  9. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/commands/__init__.py +0 -0
  10. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/commands/run/__init__.py +0 -0
  11. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/commands/run/execute.py +0 -0
  12. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/commands/worker/__init__.py +0 -0
  13. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/commands/worker/start.py +0 -0
  14. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/const/__init__.py +0 -0
  15. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/const/filestate.py +0 -0
  16. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/exception/__init__.py +0 -0
  17. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/exception/run_cancelled_exception.py +0 -0
  18. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/helper/__init__.py +0 -0
  19. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/helper/git_commit.py +0 -0
  20. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_addon_manager.py +0 -0
  21. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/__init__.py +0 -0
  22. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/abstract_process_type.py +0 -0
  23. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/family/__init__.py +0 -0
  24. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/family/abstract_process_type_family.py +0 -0
  25. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/family/file_state_process_type_family.py +0 -0
  26. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/filestate/__init__.py +0 -0
  27. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/filestate/file_state_option_process_type.py +0 -0
  28. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/filestate/file_state_report_process_type.py +0 -0
  29. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/process_type_registry.py +0 -0
  30. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/process_type/selection.py +0 -0
  31. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/py.typed +0 -0
  32. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/record/__init__.py +0 -0
  33. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/record/record_file.py +0 -0
  34. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/runner/__init__.py +0 -0
  35. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/selection/__init__.py +0 -0
  36. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/selection/abstract_code_from_directory_selection.py +0 -0
  37. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/selection/abstract_process_selection.py +0 -0
  38. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/selection/selection_registry.py +0 -0
  39. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/service/__init__.py +0 -0
  40. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/src/wexample_wex_addon_process/service/process_run_service.py +0 -0
  41. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/tests/.gitkeep +0 -0
  42. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/tests/unit/__init__.py +0 -0
  43. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/tests/unit/test_artifacts.py +0 -0
  44. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/tests/unit/test_long_runs.py +0 -0
  45. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/tests/unit/test_run_overrides.py +0 -0
  46. {wexample_wex_addon_process-3.0.1 → wexample_wex_addon_process-4.0.1}/tests/unit/test_selection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-wex-addon-process
3
- Version: 3.0.1
3
+ Version: 4.0.1
4
4
  Summary: Executes process runs defined on disk—either directly or by consuming them from a queue as a worker—against filestate selections
5
5
  Author-Email: weeger <contact@wexample.com>
6
6
  License: MIT
@@ -10,7 +10,7 @@ Classifier: Operating System :: OS Independent
10
10
  Requires-Python: >=3.10
11
11
  Requires-Dist: wexample-file>=9.2.0
12
12
  Requires-Dist: wexample-queue>=2.0.0
13
- Requires-Dist: wexample-wex-addon-app>=32.1.0
13
+ Requires-Dist: wexample-wex-addon-app>=32.3.0
14
14
  Provides-Extra: dev
15
15
  Requires-Dist: pytest; extra == "dev"
16
16
  Requires-Dist: pytest-cov; extra == "dev"
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  # wex-addon-process
20
20
 
21
- Version: 3.0.1
21
+ Version: 4.0.1
22
22
 
23
23
  `wexample-wex-addon-process` runs the treatments a board declares. A *process* says what to run and on which selection of files; a *run* is one execution of it, asked for by writing a record and picked up by a worker.
24
24
 
@@ -249,10 +249,18 @@ moves on: the run still completes, and lists what it could not do under its data
249
249
  "data": {"errors": [{"path": "docs/a.pdf", "reason": "docling timed out"}]}
250
250
  ```
251
251
 
252
+ An item left as it was on purpose — its output already up to date — is said with
253
+ `context.skip(path, reason)`, listed under `data.skipped` the same way. It counts as
254
+ dealt with and is never committed; that is what lets a long batch be started again
255
+ without redoing what is done.
256
+
257
+ A run whose type raises ends `failed`, its `error` beside the `artifacts`, `errors` and
258
+ `skipped` collected until then.
259
+
252
260
  To stop a run, write `"cancel": true` into its record. The runner checks it before every
253
261
  item (`context.each()`, `context.advance()`, or `context.check_cancel()` in a type's own
254
- loop) and ends the run `cancelled`, with `items_done` where it got to and the errors and
255
- artifacts collected so far. A run cancelled before it was taken ends `cancelled` without
262
+ loop) and ends the run `cancelled`, with `items_done` where it got to and the errors,
263
+ artifacts and skipped items collected so far. A run cancelled before it was taken ends `cancelled` without
256
264
  starting.
257
265
 
258
266
  ## Tests
@@ -358,7 +366,7 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
358
366
 
359
367
  - wexample-file: >=9.2.0
360
368
  - wexample-queue: >=2.0.0
361
- - wexample-wex-addon-app: >=32.1.0
369
+ - wexample-wex-addon-app: >=32.3.0
362
370
 
363
371
  ## Versioning & Compatibility Policy
364
372
 
@@ -1,6 +1,6 @@
1
1
  # wex-addon-process
2
2
 
3
- Version: 3.0.1
3
+ Version: 4.0.1
4
4
 
5
5
  `wexample-wex-addon-process` runs the treatments a board declares. A *process* says what to run and on which selection of files; a *run* is one execution of it, asked for by writing a record and picked up by a worker.
6
6
 
@@ -231,10 +231,18 @@ moves on: the run still completes, and lists what it could not do under its data
231
231
  "data": {"errors": [{"path": "docs/a.pdf", "reason": "docling timed out"}]}
232
232
  ```
233
233
 
234
+ An item left as it was on purpose — its output already up to date — is said with
235
+ `context.skip(path, reason)`, listed under `data.skipped` the same way. It counts as
236
+ dealt with and is never committed; that is what lets a long batch be started again
237
+ without redoing what is done.
238
+
239
+ A run whose type raises ends `failed`, its `error` beside the `artifacts`, `errors` and
240
+ `skipped` collected until then.
241
+
234
242
  To stop a run, write `"cancel": true` into its record. The runner checks it before every
235
243
  item (`context.each()`, `context.advance()`, or `context.check_cancel()` in a type's own
236
- loop) and ends the run `cancelled`, with `items_done` where it got to and the errors and
237
- artifacts collected so far. A run cancelled before it was taken ends `cancelled` without
244
+ loop) and ends the run `cancelled`, with `items_done` where it got to and the errors,
245
+ artifacts and skipped items collected so far. A run cancelled before it was taken ends `cancelled` without
238
246
  starting.
239
247
 
240
248
  ## Tests
@@ -340,7 +348,7 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
340
348
 
341
349
  - wexample-file: >=9.2.0
342
350
  - wexample-queue: >=2.0.0
343
- - wexample-wex-addon-app: >=32.1.0
351
+ - wexample-wex-addon-app: >=32.3.0
344
352
 
345
353
  ## Versioning & Compatibility Policy
346
354
 
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-wex-addon-process"
9
- version = "3.0.1"
9
+ version = "4.0.1"
10
10
  description = "Executes process runs defined on disk—either directly or by consuming them from a queue as a worker—against filestate selections"
11
11
  authors = [
12
12
  { name = "weeger", email = "contact@wexample.com" },
@@ -20,7 +20,7 @@ classifiers = [
20
20
  dependencies = [
21
21
  "wexample-file>=9.2.0",
22
22
  "wexample-queue>=2.0.0",
23
- "wexample-wex-addon-app>=32.1.0",
23
+ "wexample-wex-addon-app>=32.3.0",
24
24
  ]
25
25
 
26
26
  [project.readme]
@@ -18,6 +18,10 @@ DATA_KEY_ARTIFACTS: str = "artifacts"
18
18
  # The run still completes — one bad file does not sink the batch.
19
19
  DATA_KEY_ERRORS: str = "errors"
20
20
 
21
+ # Under a run's data: the items a type left as they were on purpose, each
22
+ # {path, reason} — an output already up to date. They count as dealt with.
23
+ DATA_KEY_SKIPPED: str = "skipped"
24
+
21
25
  DEFAULT_COMMIT_AUTHOR: str = "wex <wex@localhost>"
22
26
  """Who commits a run's work when neither the run nor git names anyone."""
23
27
 
@@ -34,6 +34,7 @@ class ProcessContext:
34
34
  touch: Callable[[Path], None] | None = None,
35
35
  artifact: Callable[[Path, str | None], None] | None = None,
36
36
  fail: Callable[[Path, str], None] | None = None,
37
+ skip: Callable[[Path, str], None] | None = None,
37
38
  check_cancel: Callable[[], None] | None = None,
38
39
  ) -> None:
39
40
  self.selection = selection
@@ -42,6 +43,7 @@ class ProcessContext:
42
43
  self._touch = touch
43
44
  self._artifact = artifact
44
45
  self._fail = fail
46
+ self._skip = skip
45
47
  self._check_cancel = check_cancel
46
48
  self.options = options
47
49
  self._advance = advance
@@ -104,6 +106,17 @@ class ProcessContext:
104
106
  if self._fail is not None:
105
107
  self._fail(path, reason)
106
108
 
109
+ def skip(self, path: Path, reason: str) -> None:
110
+ """Says that item was left as it was on purpose, and why — « output up to
111
+ date ».
112
+
113
+ Listed under `data.skipped` whatever the run ends in, so a run started
114
+ again over a long batch says what it did not need to redo. The item still
115
+ counts as dealt with, and is not touched: nothing of it is committed.
116
+ """
117
+ if self._skip is not None:
118
+ self._skip(path, reason)
119
+
107
120
  def touch(self, path: Path) -> None:
108
121
  """Says the run wrote that path — changed, created, or removed it.
109
122
 
@@ -162,6 +162,7 @@ class ProcessRunner:
162
162
  touched: set[Path] = set()
163
163
  artifacts: list[dict[str, Any]] = []
164
164
  errors: list[dict[str, str]] = []
165
+ skipped: list[dict[str, str]] = []
165
166
 
166
167
  def advance(count: int) -> None:
167
168
  progress[RUN_KEY_ITEMS_DONE] += count
@@ -204,13 +205,14 @@ class ProcessRunner:
204
205
  kernel=self.kernel,
205
206
  touch=touched.add,
206
207
  artifact=self._artifact_collector(workdir, artifacts),
207
- fail=self._error_collector(workdir, errors),
208
+ fail=self._reason_collector(workdir, errors),
209
+ skip=self._reason_collector(workdir, skipped),
208
210
  check_cancel=self._cancel_checker(run_path),
209
211
  )
210
212
  )
211
213
  except RunCancelledException:
212
214
  data: dict[str, Any] = {}
213
- self._add_collected(data, artifacts, errors)
215
+ self._add_collected(data, artifacts, errors, skipped)
214
216
  return self._write(
215
217
  run_path,
216
218
  run_id,
@@ -225,15 +227,20 @@ class ProcessRunner:
225
227
  except Exception as exception:
226
228
  self.io.debug(traceback.format_exc())
227
229
 
228
- # The file it was on stays named: a failed run says where it failed.
230
+ # The file it was on stays named: a failed run says where it failed,
231
+ # and what it did before keeps being said — a run started again over a
232
+ # long batch is told by it what is already there.
233
+ data = {}
234
+ self._add_collected(data, artifacts, errors, skipped)
229
235
  return self._fail(
230
236
  run_path,
231
237
  run_id,
232
238
  f"{type(exception).__name__}: {exception}",
233
239
  progress,
240
+ data,
234
241
  )
235
242
 
236
- self._add_collected(data, artifacts, errors)
243
+ self._add_collected(data, artifacts, errors, skipped)
237
244
 
238
245
  values: dict[str, Any] = {
239
246
  RUN_KEY_STATE: STATE_COMPLETE,
@@ -348,6 +355,7 @@ class ProcessRunner:
348
355
  touched: set[Path] = set()
349
356
  artifacts: list[dict[str, Any]] = []
350
357
  errors: list[dict[str, str]] = []
358
+ skipped: list[dict[str, str]] = []
351
359
  data = process_type.run(
352
360
  ProcessContext(
353
361
  selection=selection,
@@ -358,10 +366,11 @@ class ProcessRunner:
358
366
  kernel=self.kernel,
359
367
  touch=touched.add,
360
368
  artifact=self._artifact_collector(workdir, artifacts),
361
- fail=self._error_collector(workdir, errors),
369
+ fail=self._reason_collector(workdir, errors),
370
+ skip=self._reason_collector(workdir, skipped),
362
371
  )
363
372
  )
364
- self._add_collected(data, artifacts, errors)
373
+ self._add_collected(data, artifacts, errors, skipped)
365
374
 
366
375
  if commit:
367
376
  from wexample_wex_addon_process.const.filestate import OPTION_KEY_ACTION
@@ -381,18 +390,23 @@ class ProcessRunner:
381
390
  data: dict[str, Any],
382
391
  artifacts: list[dict[str, Any]],
383
392
  errors: list[dict[str, str]],
393
+ skipped: list[dict[str, str]],
384
394
  ) -> None:
385
395
  """Writes what every type may report the same way under its data — the files
386
- it produced, the items it could not deal with — left out when empty."""
396
+ it produced, the items it could not deal with, those it left as they were —
397
+ left out when empty."""
387
398
  from wexample_wex_addon_process.const.process import (
388
399
  DATA_KEY_ARTIFACTS,
389
400
  DATA_KEY_ERRORS,
401
+ DATA_KEY_SKIPPED,
390
402
  )
391
403
 
392
404
  if artifacts:
393
405
  data[DATA_KEY_ARTIFACTS] = artifacts
394
406
  if errors:
395
407
  data[DATA_KEY_ERRORS] = errors
408
+ if skipped:
409
+ data[DATA_KEY_SKIPPED] = skipped
396
410
 
397
411
  def _alive(self, run_path: Path) -> Any:
398
412
  """Keeps the run's record saying it is alive while the block runs.
@@ -496,23 +510,16 @@ class ProcessRunner:
496
510
  )
497
511
  return git_commit_paths(workdir, paths, message, author=author)
498
512
 
499
- def _error_collector(
500
- self, workdir: Path, errors: list[dict[str, str]]
501
- ) -> Any:
502
- """What a context calls to say an item could not be dealt with."""
503
-
504
- def collect(path: Path, reason: str) -> None:
505
- errors.append({"path": str(path.relative_to(workdir)), "reason": reason})
506
-
507
- return collect
508
-
509
513
  def _fail(
510
514
  self,
511
515
  run_path: Path,
512
516
  run_id: str,
513
517
  message: str,
514
518
  progress: dict[str, Any] | None = None,
519
+ data: dict[str, Any] | None = None,
515
520
  ) -> dict[str, Any]:
521
+ """Says the run failed, with what it had collected until then beside the
522
+ error: its artifacts are on disk all the same."""
516
523
  from wexample_wex_addon_process.const.process import (
517
524
  RUN_KEY_DATA,
518
525
  RUN_KEY_DATE_ENDED,
@@ -529,7 +536,7 @@ class ProcessRunner:
529
536
  **(progress or {}),
530
537
  RUN_KEY_STATE: STATE_FAILED,
531
538
  RUN_KEY_DATE_ENDED: self._now(),
532
- RUN_KEY_DATA: {"error": message},
539
+ RUN_KEY_DATA: {"error": message, **(data or {})},
533
540
  },
534
541
  )
535
542
 
@@ -587,6 +594,19 @@ class ProcessRunner:
587
594
  write_record(run_path, dict(progress))
588
595
  self._ring_now(run_id)
589
596
 
597
+ def _reason_collector(
598
+ self, workdir: Path, collected: list[dict[str, str]]
599
+ ) -> Any:
600
+ """What a context calls to say something of an item, and why — that it
601
+ could not be dealt with, or that it was left as it was."""
602
+
603
+ def collect(path: Path, reason: str) -> None:
604
+ collected.append(
605
+ {"path": str(path.relative_to(workdir)), "reason": reason}
606
+ )
607
+
608
+ return collect
609
+
590
610
  def _ring_now(self, run_id: str) -> None:
591
611
  import time
592
612
 
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ def _registry() -> Any:
9
+ """A type that converts each item, skips those already converted, and stops
10
+ dead on an item named `crash`."""
11
+ from wexample_wex_addon_process.process_type.abstract_process_type import (
12
+ AbstractProcessType,
13
+ )
14
+ from wexample_wex_addon_process.process_type.family.abstract_process_type_family import (
15
+ AbstractProcessTypeFamily,
16
+ )
17
+ from wexample_wex_addon_process.process_type.process_type_registry import (
18
+ ProcessTypeRegistry,
19
+ )
20
+
21
+ class ConvertType(AbstractProcessType):
22
+ label = "Convert"
23
+ name = "test:convert"
24
+
25
+ def run(self, context: Any) -> dict[str, Any]:
26
+ for path in context.each():
27
+ target = path.with_suffix(".txt")
28
+ if target.exists():
29
+ context.skip(path, "output up to date")
30
+ continue
31
+ if path.stem == "crash":
32
+ raise ConnectionError("docling unreachable")
33
+ if path.stem == "bad":
34
+ context.fail(path, "malformed")
35
+ continue
36
+ target.write_text(path.read_text())
37
+ context.artifact(target)
38
+
39
+ return {}
40
+
41
+ class ConvertFamily(AbstractProcessTypeFamily):
42
+ prefix = "test"
43
+
44
+ def types(self) -> list[AbstractProcessType]:
45
+ return [ConvertType()]
46
+
47
+ return ProcessTypeRegistry([ConvertFamily()])
48
+
49
+
50
+ def _execute(app: Path) -> dict[str, Any]:
51
+ from wexample_prompt.common.io_manager import IoManager
52
+
53
+ from wexample_wex_addon_process.runner.process_runner import ProcessRunner
54
+
55
+ data = app / ".wex" / "data"
56
+ for kind, identity, values in (
57
+ ("selection", "s", {"patterns": "docs/*.md"}),
58
+ ("process", "p", {"title": "Convert", "type": "test:convert", "selection_id": "s"}),
59
+ ("process_run", "r", {"process_id": "p", "state": "pending"}),
60
+ ):
61
+ (data / kind).mkdir(parents=True, exist_ok=True)
62
+ (data / kind / f"{identity}.json").write_text(json.dumps(values))
63
+
64
+ return ProcessRunner(io=IoManager(), registry=_registry()).execute(
65
+ workdir=app, run_id="r"
66
+ )
67
+
68
+
69
+ def _app(tmp_path: Path, *stems: str) -> Path:
70
+ app = tmp_path / "app"
71
+ (app / "docs").mkdir(parents=True)
72
+ for stem in stems:
73
+ (app / "docs" / f"{stem}.md").write_text(f"{stem}\n")
74
+ return app
75
+
76
+
77
+ def test_a_skipped_item_is_listed_and_counted(tmp_path: Path) -> None:
78
+ app = _app(tmp_path, "a", "b")
79
+ (app / "docs" / "a.txt").write_text("a\n")
80
+
81
+ run = _execute(app)
82
+
83
+ assert run["state"] == "complete", run.get("data")
84
+ assert run["data"]["skipped"] == [
85
+ {"path": "docs/a.md", "reason": "output up to date"}
86
+ ]
87
+ assert run["items_done"] == 2, "A skipped item counts as dealt with"
88
+
89
+
90
+ def test_nothing_skipped_leaves_no_key(tmp_path: Path) -> None:
91
+ run = _execute(_app(tmp_path, "a"))
92
+
93
+ assert run["state"] == "complete"
94
+ assert "skipped" not in run["data"]
95
+
96
+
97
+ def test_a_failed_run_keeps_what_it_collected(tmp_path: Path) -> None:
98
+ # Sorted: a.md is skipped, b.md converted, bad.md in error, crash.md raises.
99
+ app = _app(tmp_path, "a", "b", "bad", "crash")
100
+ (app / "docs" / "a.txt").write_text("a\n")
101
+
102
+ run = _execute(app)
103
+
104
+ assert run["state"] == "failed"
105
+ assert run["data"]["error"] == "ConnectionError: docling unreachable"
106
+ assert run["data"]["skipped"] == [
107
+ {"path": "docs/a.md", "reason": "output up to date"}
108
+ ]
109
+ assert [a["path"] for a in run["data"]["artifacts"]] == ["docs/b.txt"]
110
+ assert run["data"]["errors"] == [{"path": "docs/bad.md", "reason": "malformed"}]
111
+ assert run["current"] == "docs/crash.md", "A failed run still says where"