classifyre-cli 0.6.2__py3-none-any.whl → 0.6.4__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: classifyre-cli
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: Classifyre CLI — scan and classify unstructured data sources
5
5
  License: MIT
6
6
  Keywords: data,ingestion,metadata,pii,secrets,unstructured
@@ -1,18 +1,18 @@
1
1
  src/__init__.py,sha256=a9znVQMuKTEdH6nB8kUs7jMmPV1U6IFXDzCU5RnzxFo,23
2
2
  src/config.py,sha256=c4v6RbUQuWNIaCySaL5isNmBcUnYZimaHBW7Ki2InpU,2688
3
- src/main.py,sha256=FznnPftGLJb_awTo1NXyTIcmPNYxwfhGP00ObmKKQlo,57046
3
+ src/main.py,sha256=jvEIppZQgN2Jbw2OajJHukkSmcEnCNjUC9Dyhkdnqk4,58495
4
4
  src/telemetry.py,sha256=fbSmuJ_t-kSthfIh3E7ESVkloH22kzl8lQKY370YtuU,3089
5
5
  src/augmentation/__init__.py,sha256=pfb4zDjcyo62kHtvRl9QJ-xV28K0A_DTT_kJoF559Lc,147
6
6
  src/augmentation/contract.py,sha256=V0XQn7gk--vRSFTt7f5nC2fh8568KQR_oZ2hg3Tf20I,2716
7
7
  src/augmentation/preview.py,sha256=Bq58MfmD7bzYlRVLzhUUOqBjMavfpSurauTvSUHKsQM,9753
8
8
  src/augmentation/runner.py,sha256=0pnpYD5Nh00PaxrfS9pNk2rkGM4BnjwFUMZoVxar44w,11698
9
9
  src/augmentation/sdk.py,sha256=zpUudbk_ffVkBa6odajFCUzDE0Fuph6L0ApJeQoD0I8,17419
10
- src/augmentation/session.py,sha256=5TvvNQvrmrOgKA0-bvbHMaLhST8ZCCGNsIYCoMBiz3I,29362
10
+ src/augmentation/session.py,sha256=KXoRGbfSlb3P-SNIVD2XEW_JJS97sOT_BySQygQewN0,23009
11
11
  src/detectors/__init__.py,sha256=BWrUF_GtSsfs1nQMvG9UhOpUokqwJ0-5Dyg498VMs4E,3475
12
12
  src/detectors/base.py,sha256=bXcPmc_rZG5ONWm_ZgGLT-QpLqK8zXsdAUsguMb3FMg,2998
13
13
  src/detectors/config.py,sha256=CCrzp8Js8tQ1E34_Pl9ChY8JFjvUalXbnJJ-QWk54d4,1683
14
14
  src/detectors/dependencies.py,sha256=C5FBnkBMWCnx34WkOrM7yTO8zcOV_zP0RbyvMtw8A20,3851
15
- src/detectors/engine_version.py,sha256=smcw4YTusu6EcclPAURJozqr7aGatdctzvJ8tMaGjb0,2297
15
+ src/detectors/engine_version.py,sha256=xnSpYD0o-buNSnzuxuszR1s_4M81urdkm2ZPwXhzaLI,4030
16
16
  src/detectors/errors.py,sha256=bpmaSD21kb48Sy4OoWmPuYR4Miq3PFKNvZ1_d5_ApVc,812
17
17
  src/detectors/broken_links/__init__.py,sha256=maN50pAfC9IJGNUUIHGVM4YnjTAozRI43APLUCQrJcE,77
18
18
  src/detectors/broken_links/detector.py,sha256=k1ocQAI-cOuf6HSuipvKfdKyvpjOq5vAvIR1PmSjT3Y,9332
@@ -21,9 +21,10 @@ src/detectors/custom/__init__.py,sha256=HfTzYuQFd7XJbIAhWndqScxbbL0AS7vaOc7p3dkF
21
21
  src/detectors/custom/detector.py,sha256=SMZxVmDsj7L5UvF3eVg3qlKwFcmwo1xcvQXEULiLmzA,2407
22
22
  src/detectors/custom/extractor.py,sha256=N7LySJBR88o5hueYSZd-efhXjMT5LenWRgthoxdf9tY,9848
23
23
  src/detectors/custom/trainer.py,sha256=xS-eg8Ic6qr_rtMvSlrePFVwrzt9hwC56awSw6gTLOM,11358
24
- src/detectors/custom/runners/__init__.py,sha256=Gi8jMtIv_hIlaBOXjDmMte-cFLrH22fMEuQVW0FL_GQ,1528
24
+ src/detectors/custom/runners/__init__.py,sha256=alVRBfRhTk9q89kyvfS8eHZtSKeoL2BYrN9YxhzijzM,1607
25
25
  src/detectors/custom/runners/_base.py,sha256=-iStmAy_iqQUGrM-ZJiTbqWGh-T1h7rFR2uGNmadcAY,8485
26
- src/detectors/custom/runners/_factory.py,sha256=0Fkpe5eZqCgsQ1XHsPvpbP2oMIC3i43o5AFZPxHqej8,2227
26
+ src/detectors/custom/runners/_custom_detector.py,sha256=aWVufimAKEvnP0RiTqksQqLVmTBuACiqicMYZ85ixIs,2483
27
+ src/detectors/custom/runners/_factory.py,sha256=PpASlTeWbsqxU-WM0CV2vnFxPfZhvdmZBUe2yDrshX0,2553
27
28
  src/detectors/custom/runners/_gliner2.py,sha256=VJMab3YUBh8s6lkSLH6-Dty5WjXwGbV_cvy3YmgJtwc,18331
28
29
  src/detectors/custom/runners/_image_classification.py,sha256=_4pTRBxQ-TwfHWdVat4rI6bTX3IHPB0Ei-e9qSlFeyE,5658
29
30
  src/detectors/custom/runners/_llm.py,sha256=i47_FuUhYSjhxc31G6QcbyEpGuBLkzbuvY3GSPvOzgA,22824
@@ -31,6 +32,14 @@ src/detectors/custom/runners/_object_detection.py,sha256=QVRimQE0TjMQa6X4-FWfbEH
31
32
  src/detectors/custom/runners/_regex.py,sha256=j2a0TqHrpXQkNsmilLTb_vG7Ds_A_R4T4lVPBvaWv3U,5818
32
33
  src/detectors/custom/runners/_tag.py,sha256=A8_OSs0ly9g7asMHOpXzl4Dr3q5Hiu54vS5F1UDG7No,5947
33
34
  src/detectors/custom/runners/_text_classification.py,sha256=1vj_AIzmYBm3iBALnJDc0J6G0l86RyOSpxk0xf9-5PQ,8464
35
+ src/detectors/custom_detector/__init__.py,sha256=Dr3NXUBcNqMttLWSE8IX5NYL-KNjyLbdVDdP2vNupWA,330
36
+ src/detectors/custom_detector/contract.py,sha256=aJEBCkAu4uwAr0EoV7AnrJQrbwSGJr3aDgT111lfDaQ,1541
37
+ src/detectors/custom_detector/files.py,sha256=dbKeKRYF3nI0mV1HcaRUzj9MV0JjnJRt7Hn0QpUQpfI,3821
38
+ src/detectors/custom_detector/preview.py,sha256=STXLiHQ_WKRGtd_9FQN2n_dtRtwitYJCH4HmbpGlY3M,10481
39
+ src/detectors/custom_detector/results.py,sha256=Y6sExAYkvmhemgfsJ6EWyoNd02O70eQ0Z96Mwxx7MNU,10228
40
+ src/detectors/custom_detector/runner.py,sha256=NMK8sfQUOVwoSrbUF7fx2iBIYvPO_BngNf8kisvJn7c,9462
41
+ src/detectors/custom_detector/sdk.py,sha256=QepinCIP2l-sBrKY0wSRdVawReEOFbuoCQ5AkcR91c4,12729
42
+ src/detectors/custom_detector/session.py,sha256=gQv_VJY0HsKDv8zr1ABukPPAKTTRos0KZAPHVkNJmoI,17193
34
43
  src/detectors/pii/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
44
  src/detectors/pii/detector.py,sha256=LFNvP-SvK0nsHZsYv1TFzn9CE-M439YB3qt8ehBlcuA,45044
36
45
  src/detectors/secrets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -39,15 +48,16 @@ src/detectors/threat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hS
39
48
  src/detectors/threat/code_security_detector.py,sha256=SdjhvTSU10wHVxP2EwyzoTGMq8-J8CtwypayHVHdMUs,7049
40
49
  src/detectors/threat/yara_detector.py,sha256=VuJ6Tu3o3wMhv2bskSx2YaB7sTILuQaZlK0uWEiLqe0,6549
41
50
  src/file_evaluation/__init__.py,sha256=S2IF_CeZtxbfcVKcEamdyW1mZVb-fHAuWqt8QE999Rg,141
42
- src/file_evaluation/runner.py,sha256=losdZP_xu2ArPSWNajqJtlQl7wEveeF_uN8jmUkpxNE,13401
51
+ src/file_evaluation/asset_fixture.py,sha256=6fcEXlIxYYVE7xJpGtH28Zhmwjdh4ewxqfJlCJSOZ8Y,5192
52
+ src/file_evaluation/runner.py,sha256=_hZ9ewrnnU-QkUmK19MErA57AZ06TQqAzncoeVc9eOM,17379
43
53
  src/graph/__init__.py,sha256=9Uhsf3oCiG_w49rwoHQ7fTtsER4WyYTrEIBCE8RsqYc,585
44
54
  src/graph/edges.py,sha256=ct9tDheI3pklYwX0mtiSet6Sdy_9E4ATEwa5pqdHsOA,15942
45
- src/models/generated_detectors.py,sha256=0IZFBhSi_cEtqlCBnGBTwjwjOKPvG2MVp2vfX5tzJYw,54300
55
+ src/models/generated_detectors.py,sha256=cpJLlneiViU4cvD6-o-j5FwWXT1TvvElkoHNTtwgKhg,63159
46
56
  src/models/generated_input.py,sha256=EE4ESBNBm9h5cwq4nKICvJGM3FAZlSYwVhyTOxzWF0o,205362
47
- src/models/generated_single_asset_scan_results.py,sha256=yTwkgC9X-GPT6YuhEanO5GlXegn_2FjqKoplcoTCtEs,16049
57
+ src/models/generated_single_asset_scan_results.py,sha256=zJ78x8GsjpiOBWG5aOQHHVBHdjQeosmCPQ2etwYxAv4,16508
48
58
  src/notebook/__init__.py,sha256=IbgAR0i8AKkvBn9QFPwOy5iJUUeDk-Vkcnhoz2nSHBc,985
49
- src/notebook/child.py,sha256=5vamvuLvyjETdOrjg0cZGtSnr3iD9oRY6XdIYHGEpew,8712
50
- src/notebook/cli.py,sha256=y2wIL7ePh9StX-2Zh8LzFj1DuPetRUmQ9fzZVhY_Ob8,12606
59
+ src/notebook/child.py,sha256=7ZXQ6dBwonPahl2g_tXmlSa-XiXUlBgGJwCU60BA7-U,8944
60
+ src/notebook/cli.py,sha256=jrfvd3ffktleIhHZb7-dm-3bUiaMBfoYiXcfYSIaQ2I,14925
51
61
  src/notebook/cohort.py,sha256=L2bcx5cvYeBHfJBboQPh2Bw9hsRDBhZ4GUwCieIUKP4,7137
52
62
  src/notebook/contract.py,sha256=S2_oyHBWqPxmJ3s_5soh7RXE0VBudw8SUOsanggNPsU,10331
53
63
  src/notebook/execute.py,sha256=pfkphtwWxmGsJtnJzcXwqBtFx-c8feH9oyl7X_Yb7Uk,21019
@@ -55,7 +65,7 @@ src/notebook/files.py,sha256=3Ef1r_udTM1k8AKNL-wlE135Bczo6Er_s8a90IZ1xUI,6584
55
65
  src/notebook/groups.py,sha256=bl4Rqc4ubjI7kuUCSKAQBTwswtHZlpipFuLh8vc4irU,4366
56
66
  src/notebook/outputs.py,sha256=GcfnUYitRwrfZlrZ_qPNuNhHwtqxl9_UXSX4VT1cIgI,5194
57
67
  src/notebook/packages.py,sha256=mkxFOSmQNja7eqSpXkpe1LesPYfb7MmMt11kkR0QRCU,5827
58
- src/notebook/protocol.py,sha256=Cpxneux0g-6d0VbBZASUEWqjPYyDk0BwWy_gTqeg51c,7869
68
+ src/notebook/protocol.py,sha256=X9T5zkyh1YEw_bUtvD-GVaF4v4s1ZgBQUI-MSql1hEY,9277
59
69
  src/notebook/redact.py,sha256=OYp7ugsmIuscgW6oJFb-K-HxQlUI3sSkeR3IkqAt_2o,981
60
70
  src/notebook/scaffold.py,sha256=_vDjiuJEbCIc-4HAa19y9zQ8RU69oMEWaWNchWa62Jw,2283
61
71
  src/notebook/sdk.py,sha256=zELFrZC03y48_x3KtiwAsnThE0LJV2GvW9c8gYSy2Q0,40557
@@ -67,12 +77,13 @@ src/outputs/factory.py,sha256=ritKokn9SM6s51MSCPJhLKQMwhKF4OBRatsntGYi22w,5397
67
77
  src/outputs/file.py,sha256=6y585mxJS04MgTcMu5KPkJt_hm9qLjnbPEzD4MgrmI4,2594
68
78
  src/outputs/rest.py,sha256=Yew1iPPR1j1ljYlLQhILYNeOt5Jj6cpB11-RnRDX-VY,25848
69
79
  src/pipeline/__init__.py,sha256=BCWl3hwwLkmZc2vQ2fEyUdhis4TPK930HEgXTog8NjA,742
80
+ src/pipeline/asset_payload_server.py,sha256=6LPEcybDk6gKYw6cSVXRKAHmZhkczwkjt3YgE6kN644,9773
70
81
  src/pipeline/content_provider.py,sha256=XiSOED7Lw0S663ybZo4iveQRceg5f51tdU9tyv2erSg,1078
71
82
  src/pipeline/detector_breaker.py,sha256=Mn1F7K7LM7jIJpgbZEJVmApAxLRrK-W1LRgpP8ZPuMw,7969
72
- src/pipeline/detector_pipeline.py,sha256=yLkTWdRsv_lN3S4QzhZo9ano-jcmLWV123OikyLu8eY,60123
83
+ src/pipeline/detector_pipeline.py,sha256=X6FC58QAYgtBE_83SM2AnHCIb9QMhF4kR7A0wgMi-dg,65851
73
84
  src/pipeline/parsed_content_provider.py,sha256=rHTvvBvjTQtCJF3HT8GC1oSZV3eQ2PbgZ2tCtLIPVOE,4382
74
85
  src/pipeline/payload_window.py,sha256=H5aDNuaLxei_zI0Hk0FQsiGNCX6ixcXa4ffV3Inglp0,25254
75
- src/pipeline/scan_cache.py,sha256=a28icQ_8zs-2z5vXrV77XcdS7EIY8P5fg28XjgdW70A,24240
86
+ src/pipeline/scan_cache.py,sha256=0rZwP1Hv29yPWY50OqfreLhtE7Rn8HS5sB3ZEZpBsmg,26475
76
87
  src/pipeline/text_artifact.py,sha256=Ltu6wbpxnE8CdStC4E41y5VzhXFEgv00OhsI4Bv0YJo,1656
77
88
  src/pipeline/worker_pool.py,sha256=Yjr_2LXxV2nYNcXNN4gFRPK9KN15GP_IkkAzaplKaTc,10543
78
89
  src/sources/__init__.py,sha256=wYksO_0IcvlGXLouWapKyqzL0jG0El4rDDGRfwa4Zqk,3350
@@ -182,7 +193,7 @@ src/utils/hashing.py,sha256=T0GL1FWtG32QJID5RN3X-TzHHkolnNK6EfADvX6sdlE,2490
182
193
  src/utils/legacy_office.py,sha256=LCDiU7E-ZUxHgtQieihQWCXNXOtjiUpJEExWjme-dPI,9205
183
194
  src/utils/payload.py,sha256=ewvSABOLSiIGnrq5A9wfOeFZnVJ_TXtSmEOxDYcE58U,6159
184
195
  src/utils/range_reader.py,sha256=tIT4oicfPT4B_hJyijVmB-SA8-HOj4Gol0Fv0Crw6s0,8661
185
- src/utils/redaction.py,sha256=elhNyCGR9DxzS8FSTDsPuDTwVIsEku-pd4vJv8l0SKw,11621
196
+ src/utils/redaction.py,sha256=9LYP_R58plMe_6fvpoOCnM0FcTHh-W7p4iOthExNygQ,12552
186
197
  src/utils/resources.py,sha256=2ejFjz9r_Kv_d-sUGD0oj54U-ZIUSxDAu7aD4YEv4FQ,2630
187
198
  src/utils/source_files.py,sha256=OKZSW64cur0HTuWPRkV1sJg_Z2LFGWzUsC853gGlFBY,5033
188
199
  src/utils/sql_lineage.py,sha256=uvbiH9SQVDflToFsJqFs32rVwV2K2FG6VFRxIIJZxkI,8024
@@ -192,7 +203,7 @@ src/utils/urn.py,sha256=lzq5hEi0schfHh83Gs3oFQoI2qsk3klCev_aatMyI_E,11189
192
203
  src/utils/uv_sync.py,sha256=VZ0TZ7t3_pSjxOb20BxUs2XLVjigXnVqa5y69XWlnE8,9307
193
204
  src/utils/validation.py,sha256=5NHsgyZwyEONRu9_ADxUAQNPvbMnpMhKL-hpgFEVMm8,3837
194
205
  src/utils/video_processing.py,sha256=B6Br9ybFEa_ioglJGo0P9Fmp1e2B95C24joAktwrjww,11293
195
- classifyre_cli-0.6.2.dist-info/METADATA,sha256=7rRt9wjX1hGvk4B-Mfl07P0vxaAthJ7d_2roXOfKeNo,5850
196
- classifyre_cli-0.6.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
197
- classifyre_cli-0.6.2.dist-info/entry_points.txt,sha256=edxllyMiIFfajgCZ3dTRACXYGC34ZX1AYTezMSR5E_8,45
198
- classifyre_cli-0.6.2.dist-info/RECORD,,
206
+ classifyre_cli-0.6.4.dist-info/METADATA,sha256=-0x1bhBf5oDBn34RF62GKWi8Xw7C_RfPyr3YT29jmVw,5850
207
+ classifyre_cli-0.6.4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
208
+ classifyre_cli-0.6.4.dist-info/entry_points.txt,sha256=edxllyMiIFfajgCZ3dTRACXYGC34ZX1AYTezMSR5E_8,45
209
+ classifyre_cli-0.6.4.dist-info/RECORD,,
@@ -18,7 +18,6 @@ silently-degraded run visible.
18
18
  from __future__ import annotations
19
19
 
20
20
  import asyncio
21
- import base64
22
21
  import json
23
22
  import logging
24
23
  from collections.abc import Mapping
@@ -29,6 +28,7 @@ from ..graph.edges import edge_from_payload
29
28
  from ..notebook.child import ChildProcessError, NotebookChildProcess
30
29
  from ..notebook.groups import warm_declared_groups
31
30
  from ..notebook.packages import install as install_packages
31
+ from ..pipeline.asset_payload_server import AssetPayloadServer
32
32
  from ..utils.hashing import unhash_id
33
33
  from ..utils.urn import normalize_urn_or_none
34
34
  from .contract import validate_augmentation_notebook
@@ -143,10 +143,6 @@ class AugmentationSession:
143
143
  self._warnings: list[str] = []
144
144
  self._warning_set: set[str] = set()
145
145
  self.stats = AugmentationStats()
146
- # Run-scoped memo of fetched payloads, keyed by fetch candidate id.
147
- # Bounded: one in-flight asset's worth per entry, each capped at
148
- # max_payload_bytes, dropped by evict_asset_cache().
149
- self._memo: dict[str, dict[str, Any]] = {}
150
146
 
151
147
  # -- construction ----------------------------------------------------------
152
148
 
@@ -295,7 +291,6 @@ class AugmentationSession:
295
291
  slot.child.terminate()
296
292
  except Exception:
297
293
  pass
298
- self._memo.clear()
299
294
 
300
295
  # -- per asset -----------------------------------------------------------------
301
296
 
@@ -314,163 +309,34 @@ class AugmentationSession:
314
309
  "mime_type": None,
315
310
  }
316
311
 
317
- def _candidates(self, asset: Any) -> list[str]:
318
- seen: list[str] = []
319
- for candidate in (getattr(asset, "external_url", ""), getattr(asset, "hash", "")):
320
- value = str(candidate or "").strip()
321
- if value and value not in seen:
322
- seen.append(value)
323
- return seen
312
+ @property
313
+ def _payloads(self) -> AssetPayloadServer:
314
+ """The source's shared payload server.
315
+
316
+ Shared with the code detectors that run on the same asset later in the
317
+ scan, so a payload augmentation fetched is not fetched again (G1 R20).
318
+ """
319
+ return AssetPayloadServer.shared(
320
+ self._source, max_payload_bytes=self._limits["max_payload_bytes"]
321
+ )
324
322
 
325
323
  async def _serve_need(self, asset: Any, memo_key: str, frame: dict[str, Any]) -> Any:
326
- op = str(frame.get("op") or "")
327
- if op == "payload":
328
- fetched = await self._fetch_payload(asset)
329
- if fetched is None:
330
- return {"bytes_b64": None, "mime": None}
331
- raw, mime = fetched
332
- return {"bytes_b64": base64.b64encode(raw).decode("ascii"), "mime": mime}
333
- if op == "raw_pages":
334
- pages = await self._fetch_raw_pages(asset)
335
- return {"pages": pages}
336
- if op == "text":
337
- pages = await self._fetch_text_pages(asset)
338
- whole = "".join(pages)
339
- return {"text": whole, "pages": pages}
340
- raise ChildProcessError(f"Unknown payload op {op!r}")
324
+ try:
325
+ return await self._payloads.serve(asset, frame)
326
+ except ValueError as exc:
327
+ raise ChildProcessError(str(exc)) from exc
341
328
 
342
329
  async def _fetch_payload(self, asset: Any) -> tuple[bytes, str] | None:
343
330
  """Raw bytes for file-shaped sources; None for row-shaped ones."""
344
- memo = self._memo_for(asset)
345
- if "payload" in memo:
346
- cached = memo["payload"]
347
- return cached if cached is None else (bytes(cached[0]), str(cached[1]))
348
- fetch = getattr(self._source, "fetch_content_bytes", None)
349
- result: tuple[bytes, str] | None = None
350
- if callable(fetch):
351
- for candidate in self._candidates(asset):
352
- try:
353
- result = await fetch(candidate)
354
- except Exception as exc:
355
- logger.debug("Augmentation payload fetch failed for %s: %s", candidate, exc)
356
- continue
357
- if result is not None:
358
- break
359
- if result is not None:
360
- raw, mime = result
361
- cap = self._limits["max_payload_bytes"]
362
- if len(raw) > cap:
363
- logger.debug("Augmentation payload for %s truncated to %d bytes", candidate, cap)
364
- raw = raw[:cap]
365
- result = (raw, mime)
366
- self._remember_bytes(asset, raw, mime)
367
- memo["payload"] = result
368
- return result
331
+ return await self._payloads.payload(asset)
369
332
 
370
333
  async def _fetch_raw_pages(self, asset: Any) -> list[str]:
371
334
  """The connector's own raw representation, page by page (capped)."""
372
- memo = self._memo_for(asset)
373
- if "raw_pages" in memo:
374
- return [str(page) for page in memo["raw_pages"]]
375
- pages: list[str] = []
376
- fetch = getattr(self._source, "fetch_content_pages", None)
377
- cap = self._limits["max_payload_bytes"]
378
- used = 0
379
- if callable(fetch):
380
- for candidate in self._candidates(asset):
381
- try:
382
- async for raw, _text in fetch(candidate):
383
- if not raw:
384
- continue
385
- chunk = str(raw)
386
- if used + len(chunk) > cap:
387
- chunk = chunk[: max(0, cap - used)]
388
- if chunk:
389
- pages.append(chunk)
390
- used += len(chunk)
391
- if used >= cap or len(pages) >= 50:
392
- break
393
- if pages:
394
- break
395
- except Exception as exc:
396
- logger.debug("Augmentation raw fetch failed for %s: %s", candidate, exc)
397
- continue
398
- memo["raw_pages"] = pages
399
- return pages
335
+ return await self._payloads.raw_pages(asset)
400
336
 
401
337
  async def _fetch_text_pages(self, asset: Any) -> list[str]:
402
338
  """Extracted text, page by page (capped)."""
403
- memo = self._memo_for(asset)
404
- if "text_pages" in memo:
405
- return [str(page) for page in memo["text_pages"]]
406
- pages: list[str] = []
407
- fetch = getattr(self._source, "fetch_content_pages", None)
408
- cap = self._limits["max_payload_bytes"]
409
- used = 0
410
- if callable(fetch):
411
- for candidate in self._candidates(asset):
412
- try:
413
- async for _raw, text in fetch(candidate):
414
- if not text:
415
- continue
416
- chunk = str(text)
417
- if used + len(chunk) > cap:
418
- chunk = chunk[: max(0, cap - used)]
419
- if chunk:
420
- pages.append(chunk)
421
- used += len(chunk)
422
- if used >= cap or len(pages) >= 50:
423
- break
424
- if pages:
425
- break
426
- except Exception as exc:
427
- logger.debug("Augmentation text fetch failed for %s: %s", candidate, exc)
428
- continue
429
- if not pages:
430
- provider = getattr(self._source, "fetch_content_bytes", None)
431
- if callable(provider):
432
- fetched = await self._fetch_payload(asset)
433
- if fetched is not None:
434
- raw, mime = fetched
435
- try:
436
- text_pages = await asyncio.to_thread(
437
- list, self._source.iter_asset_pages(raw, mime)
438
- )
439
- for page in text_pages[:50]:
440
- chunk = str(page)
441
- if used + len(chunk) > cap:
442
- chunk = chunk[: max(0, cap - used)]
443
- if chunk:
444
- pages.append(chunk)
445
- used += len(chunk)
446
- if used >= cap:
447
- break
448
- except Exception as exc:
449
- logger.debug("Augmentation text fallback failed: %s", exc)
450
- memo["text_pages"] = pages
451
- return pages
452
-
453
- def _memo_for(self, asset: Any) -> dict[str, Any]:
454
- key = str(getattr(asset, "hash", "") or "")
455
- return self._memo.setdefault(key, {})
456
-
457
- def _remember_bytes(self, asset: Any, raw: bytes, mime: str) -> None:
458
- """Mirror fetched bytes onto the source so the pipeline reuses them.
459
-
460
- ``ParsedContentProvider.fetch_bytes`` consults this memo first instead
461
- of re-downloading — the fetch augmentation already paid for is not
462
- paid twice. Registered under every candidate id the pipeline tries
463
- (external URL, then hash), and dropped by ``evict`` at the end of the
464
- asset.
465
- """
466
- remember = getattr(self._source, "remember_augmentation_bytes", None)
467
- if not callable(remember):
468
- return
469
- for candidate in self._candidates(asset):
470
- try:
471
- remember(candidate, raw, mime)
472
- except Exception:
473
- pass
339
+ return await self._payloads.text_pages(asset)
474
340
 
475
341
  async def augment(self, asset: Any) -> Any:
476
342
  """Enrich one asset. Never raises: failure degrades to a warning."""
@@ -718,13 +584,4 @@ class AugmentationSession:
718
584
 
719
585
  def evict(self, asset: Any) -> None:
720
586
  """Drop one asset's payload memo. Called alongside evict_asset_cache."""
721
- self._memo.pop(str(getattr(asset, "hash", "") or ""), None)
722
- forget = getattr(self._source, "forget_augmentation_bytes", None)
723
- if not callable(forget):
724
- return
725
- # The bytes were mirrored under every candidate id; drop them all.
726
- for candidate in self._candidates(asset):
727
- try:
728
- forget(candidate)
729
- except Exception:
730
- pass
587
+ self._payloads.evict(asset)
@@ -13,6 +13,7 @@ from ._base import (
13
13
  BaseRunner,
14
14
  _resolve_pipeline_severity,
15
15
  )
16
+ from ._custom_detector import CustomDetectorRunner
16
17
  from ._factory import create_runner
17
18
  from ._gliner2 import (
18
19
  GLiNER2Runner,
@@ -35,6 +36,7 @@ __all__ = [
35
36
  "_IMAGE_CONTENT_TYPES",
36
37
  "_TEXT_CONTENT_TYPES",
37
38
  "BaseRunner",
39
+ "CustomDetectorRunner",
38
40
  "GLiNER2Runner",
39
41
  "ImageClassificationRunner",
40
42
  "LLMRunner",
@@ -0,0 +1,72 @@
1
+ """Runner for the ``CODE_DETECTOR`` pipeline type: a code detector.
2
+
3
+ Like ``TAG`` it never runs over a page of text: ``detect()`` returns nothing and
4
+ no content type is advertised, so the text, binary and link passes never
5
+ schedule it and the worker pool never receives it. The detector pipeline
6
+ recognises it by this class and calls it once per asset, after every other
7
+ detector, through the :class:`CustomDetectorSession` it owns.
8
+
9
+ The session is created lazily on first use and bound to the scan's source,
10
+ because it needs the source to serve payload and holds child processes that
11
+ must not exist in the worker pool's processes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from ....models.generated_detectors import CodeDetectorPipelineSchema, PipelineResult
19
+ from ....models.generated_single_asset_scan_results import DetectionResult
20
+ from ._base import BaseRunner
21
+
22
+
23
+ class CustomDetectorRunner(BaseRunner):
24
+ """Asset-scoped runner: judged per asset by a notebook, never per page."""
25
+
26
+ asset_scoped = True
27
+
28
+ def __init__(
29
+ self,
30
+ schema: CodeDetectorPipelineSchema,
31
+ detector_key: str = "",
32
+ detector_name: str = "",
33
+ ) -> None:
34
+ self.schema = schema
35
+ self._detector_key = detector_key
36
+ self._detector_name = detector_name
37
+ self._session: Any = None
38
+
39
+ def run(self, text: str) -> PipelineResult:
40
+ return PipelineResult(metadata={"runner": "CODE_DETECTOR"})
41
+
42
+ def detect(self, content: str | bytes, content_type: str) -> list[DetectionResult]:
43
+ """Never called per page. The pipeline calls ``session().detect(asset)``."""
44
+ return []
45
+
46
+ def get_supported_content_types(self) -> list[str]:
47
+ return []
48
+
49
+ @property
50
+ def deterministic(self) -> bool:
51
+ return self.schema.deterministic is not False
52
+
53
+ @property
54
+ def needs_findings(self) -> bool:
55
+ return bool(self.schema.needs_findings)
56
+
57
+ def session(self, source: Any) -> Any:
58
+ """The run's session, created on first use."""
59
+ if self._session is None:
60
+ from ...custom_detector.session import CustomDetectorSession
61
+
62
+ self._session = CustomDetectorSession(
63
+ key=self._detector_key,
64
+ name=self._detector_name,
65
+ schema=self.schema,
66
+ source=source,
67
+ )
68
+ return self._session
69
+
70
+ def close(self) -> None:
71
+ if self._session is not None:
72
+ self._session.close()
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from ....models.generated_detectors import (
6
+ CodeDetectorPipelineSchema,
6
7
  GLiNER2PipelineSchema,
7
8
  ImageClassificationPipelineSchema,
8
9
  LLMPipelineSchema,
@@ -12,6 +13,7 @@ from ....models.generated_detectors import (
12
13
  TextClassificationPipelineSchema,
13
14
  )
14
15
  from ._base import BaseRunner
16
+ from ._custom_detector import CustomDetectorRunner
15
17
  from ._gliner2 import GLiNER2Runner
16
18
  from ._image_classification import ImageClassificationRunner
17
19
  from ._llm import LLMRunner
@@ -30,6 +32,7 @@ def create_runner(
30
32
  | ImageClassificationPipelineSchema
31
33
  | ObjectDetectionPipelineSchema
32
34
  | TagPipelineSchema
35
+ | CodeDetectorPipelineSchema
33
36
  ),
34
37
  detector_key: str = "",
35
38
  detector_name: str = "",
@@ -50,5 +53,8 @@ def create_runner(
50
53
  # detector that is not supposed to run anything at all.
51
54
  if isinstance(schema, TagPipelineSchema):
52
55
  return TagRunner(schema, detector_key, detector_name)
56
+ # Same reason as TAG: a code detector must never fall through to GLiNER2.
57
+ if isinstance(schema, CodeDetectorPipelineSchema):
58
+ return CustomDetectorRunner(schema, detector_key, detector_name)
53
59
  # GLiNER2PipelineSchema is the default / backward-compat path
54
60
  return GLiNER2Runner(schema, detector_key, detector_name)
@@ -0,0 +1,9 @@
1
+ """Code detectors: the ``CODE_DETECTOR`` pipeline type.
2
+
3
+ A notebook that defines ``detect(asset, ctx)`` and yields ``Finding`` objects,
4
+ run in an isolated child process once per asset, after every other detector.
5
+ """
6
+
7
+ from .session import CustomDetectorSession, DetectOutcome
8
+
9
+ __all__ = ["CustomDetectorSession", "DetectOutcome"]
@@ -0,0 +1,45 @@
1
+ """The interface a code detector's notebook must cover.
2
+
3
+ A code detector answers one question -- "what is wrong with this asset?" -- so
4
+ ``detect(asset, ctx)`` is required and ``setup(ctx)`` optional. Checked at the
5
+ three moments the connector contract is: on save (the API's cheap check),
6
+ before a preview, and before every scan, because the recipe travels to a job
7
+ that has to defend itself.
8
+
9
+ Only the shape is checked, through the shared ``validate_module`` /
10
+ ``validate_notebook``. Isolation is the child process and its scrubbed
11
+ environment, not anything an AST can decide.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Iterable, Mapping
17
+
18
+ from ...notebook.contract import ContractReport, validate_module, validate_notebook
19
+ from ...notebook.serialize import ModuleSource
20
+
21
+ REQUIRED_FUNCTIONS = ("detect",)
22
+
23
+ #: ``setup(ctx)`` runs once per worker before the first asset: load a list,
24
+ #: a model, a lookup table into ``ctx.state``.
25
+ OPTIONAL_FUNCTIONS = ("setup",)
26
+
27
+
28
+ def validate_detector_module(module: ModuleSource) -> ContractReport:
29
+ """Parse the assembled module and check ``detect()`` exists."""
30
+ return validate_module(module, required=REQUIRED_FUNCTIONS)
31
+
32
+
33
+ def validate_detector_notebook(
34
+ cells: Iterable[Mapping[str, object] | object],
35
+ ) -> ContractReport:
36
+ """Validate a code detector's cells."""
37
+ return validate_notebook(cells, required=REQUIRED_FUNCTIONS)
38
+
39
+
40
+ __all__ = [
41
+ "OPTIONAL_FUNCTIONS",
42
+ "REQUIRED_FUNCTIONS",
43
+ "validate_detector_module",
44
+ "validate_detector_notebook",
45
+ ]
@@ -0,0 +1,102 @@
1
+ """Materialize a code detector's uploaded files for ``ctx.file(name)``.
2
+
3
+ Downloaded by the parent, never by the child: the child is started without the
4
+ API URL or the callback key, and handing it either would undo the isolation it
5
+ exists for. The API injects ``files_runtime`` -- one entry per file, with a
6
+ content hash and a download URL -- into the pipeline schema at dispatch.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import re
14
+ import shutil
15
+ import tempfile
16
+ from collections.abc import Iterable, Mapping
17
+ from pathlib import Path
18
+ from typing import Any
19
+ from urllib.parse import quote
20
+
21
+ import requests
22
+
23
+ from ...outputs.base import DEFAULT_REST_TIMEOUT_SEC
24
+ from ...outputs.rest import INTERNAL_KEY_HEADER
25
+ from ...utils.source_files import CHUNK_BYTES, api_base_url
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ #: A directory prepared by the caller (the preview job's init container, a
30
+ #: test) wins over downloading.
31
+ FILES_DIR_ENV = "CLASSIFYRE_DETECTOR_FILES_DIR"
32
+
33
+
34
+ def _safe_name(name: str) -> str:
35
+ base = Path(name).name or "file"
36
+ return re.sub(r"[^A-Za-z0-9._ -]", "_", base)[:200] or "file"
37
+
38
+
39
+ def _entries(files_runtime: Iterable[Any] | None) -> list[dict[str, Any]]:
40
+ entries: list[dict[str, Any]] = []
41
+ for raw in files_runtime or []:
42
+ entry = raw.model_dump() if hasattr(raw, "model_dump") else raw
43
+ if isinstance(entry, Mapping) and entry.get("id") and entry.get("name"):
44
+ entries.append(dict(entry))
45
+ return entries
46
+
47
+
48
+ def materialize_detector_files(
49
+ detector_id: str | None,
50
+ files_runtime: Iterable[Any] | None,
51
+ ) -> Path | None:
52
+ """Download every file into a fresh temp dir; None when there are none.
53
+
54
+ A file that fails to download is logged and skipped: a rule that reads one
55
+ list out of three should still see the two that arrived, and a rule that
56
+ needs the missing one fails loudly on ``ctx.file(name)`` with its name.
57
+ """
58
+ prepared = os.environ.get(FILES_DIR_ENV)
59
+ if prepared and Path(prepared).is_dir():
60
+ return Path(prepared)
61
+ entries = _entries(files_runtime)
62
+ if not entries:
63
+ return None
64
+ destination = Path(tempfile.mkdtemp(prefix="classifyre-detector-files-"))
65
+ headers = {"Connection": "close"}
66
+ key = os.environ.get("CLASSIFYRE_INTERNAL_KEY", "").strip()
67
+ if key:
68
+ headers[INTERNAL_KEY_HEADER] = key
69
+ written = 0
70
+ with requests.Session() as session:
71
+ for entry in entries:
72
+ url = str(entry.get("url") or "").strip()
73
+ if not url:
74
+ if not detector_id:
75
+ continue
76
+ url = (
77
+ f"{api_base_url()}/custom-detectors/{quote(detector_id, safe='')}"
78
+ f"/files/{quote(str(entry['id']), safe='')}/content"
79
+ )
80
+ target = destination / _safe_name(str(entry["name"]))
81
+ try:
82
+ with session.get(
83
+ url, headers=headers, stream=True, timeout=(30, DEFAULT_REST_TIMEOUT_SEC)
84
+ ) as response:
85
+ response.raise_for_status()
86
+ with target.open("wb") as handle:
87
+ for chunk in response.iter_content(chunk_size=CHUNK_BYTES):
88
+ if chunk:
89
+ handle.write(chunk)
90
+ except (requests.RequestException, OSError) as exc:
91
+ logger.warning("Could not download detector file %s: %s", entry.get("name"), exc)
92
+ target.unlink(missing_ok=True)
93
+ continue
94
+ written += 1
95
+ if not written:
96
+ shutil.rmtree(destination, ignore_errors=True)
97
+ return None
98
+ logger.info("Made %d detector file(s) available to ctx.files", written)
99
+ return destination
100
+
101
+
102
+ __all__ = ["FILES_DIR_ENV", "materialize_detector_files"]