classifyre-cli 0.4.45__tar.gz → 0.4.47__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 (234) hide show
  1. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/.turbo/turbo-build.log +1 -1
  2. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/PKG-INFO +1 -1
  3. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/package.json +1 -1
  4. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/pyproject.toml +1 -1
  5. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/models/generated_input.py +84 -3
  6. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/azure_blob_storage/source.py +18 -2
  7. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/google_cloud_storage/source.py +20 -2
  8. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/kafka/source.py +61 -3
  9. classifyre_cli-0.4.47/src/sources/local_folder/__init__.py +3 -0
  10. classifyre_cli-0.4.47/src/sources/local_folder/source.py +146 -0
  11. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/object_storage/base.py +14 -0
  12. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/s3_compatible_storage/source.py +14 -1
  13. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/slack/source.py +2 -3
  14. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/youtube/source.py +2 -2
  15. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_azure_blob_storage_source.py +71 -0
  16. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_google_cloud_storage_source.py +65 -0
  17. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_kafka_source.py +108 -10
  18. classifyre_cli-0.4.47/tests/test_local_folder_source.py +335 -0
  19. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_s3_compatible_storage_source.py +65 -0
  20. classifyre_cli-0.4.47/tests/test_source_strategy_conformance.py +235 -0
  21. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/uv.lock +5 -5
  22. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/.gitignore +0 -0
  23. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/.python-version +0 -0
  24. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/README.md +0 -0
  25. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/main.py +0 -0
  26. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/scripts/generate_models.py +0 -0
  27. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/__init__.py +0 -0
  28. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/config.py +0 -0
  29. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/__init__.py +0 -0
  30. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/base.py +0 -0
  31. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/broken_links/__init__.py +0 -0
  32. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/broken_links/detector.py +0 -0
  33. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/config.py +0 -0
  34. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/content/__init__.py +0 -0
  35. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/__init__.py +0 -0
  36. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/detector.py +0 -0
  37. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/extractor.py +0 -0
  38. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/__init__.py +0 -0
  39. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_base.py +0 -0
  40. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_factory.py +0 -0
  41. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_feature_extraction.py +0 -0
  42. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_gliner2.py +0 -0
  43. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_image_classification.py +0 -0
  44. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_llm.py +0 -0
  45. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_object_detection.py +0 -0
  46. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_regex.py +0 -0
  47. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_text_classification.py +0 -0
  48. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/trainer.py +0 -0
  49. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/dependencies.py +0 -0
  50. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/pii/__init__.py +0 -0
  51. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/pii/detector.py +0 -0
  52. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/secrets/__init__.py +0 -0
  53. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/secrets/detector.py +0 -0
  54. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/threat/__init__.py +0 -0
  55. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/threat/code_security_detector.py +0 -0
  56. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/threat/yara_detector.py +0 -0
  57. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/main.py +0 -0
  58. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/models/generated_detectors.py +0 -0
  59. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/models/generated_single_asset_scan_results.py +0 -0
  60. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/__init__.py +0 -0
  61. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/base.py +0 -0
  62. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/console.py +0 -0
  63. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/factory.py +0 -0
  64. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/file.py +0 -0
  65. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/rest.py +0 -0
  66. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/pipeline/__init__.py +0 -0
  67. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/pipeline/content_provider.py +0 -0
  68. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/pipeline/detector_pipeline.py +0 -0
  69. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/pipeline/parsed_content_provider.py +0 -0
  70. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/pipeline/worker_pool.py +0 -0
  71. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sandbox/__init__.py +0 -0
  72. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sandbox/runner.py +0 -0
  73. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/__init__.py +0 -0
  74. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/asset_metadata.py +0 -0
  75. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/atlassian_common.py +0 -0
  76. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/azure_blob_storage/__init__.py +0 -0
  77. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/base.py +0 -0
  78. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/confluence/__init__.py +0 -0
  79. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/confluence/source.py +0 -0
  80. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/databricks/__init__.py +0 -0
  81. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/databricks/source.py +0 -0
  82. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/delta_lake/__init__.py +0 -0
  83. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/delta_lake/source.py +0 -0
  84. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/dependencies.py +0 -0
  85. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/elasticsearch/__init__.py +0 -0
  86. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/elasticsearch/source.py +0 -0
  87. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/email/__init__.py +0 -0
  88. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/email/source.py +0 -0
  89. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/google_cloud_storage/__init__.py +0 -0
  90. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/hive/__init__.py +0 -0
  91. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/hive/source.py +0 -0
  92. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/iceberg/__init__.py +0 -0
  93. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/iceberg/source.py +0 -0
  94. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/jira/__init__.py +0 -0
  95. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/jira/source.py +0 -0
  96. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/kafka/__init__.py +0 -0
  97. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/lakehouse_base.py +0 -0
  98. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/meilisearch/__init__.py +0 -0
  99. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/meilisearch/source.py +0 -0
  100. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mongodb/__init__.py +0 -0
  101. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mongodb/source.py +0 -0
  102. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mssql/__init__.py +0 -0
  103. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mssql/source.py +0 -0
  104. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mysql/__init__.py +0 -0
  105. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mysql/source.py +0 -0
  106. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/neo4j/__init__.py +0 -0
  107. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/neo4j/source.py +0 -0
  108. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/notion/__init__.py +0 -0
  109. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/notion/client.py +0 -0
  110. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/notion/source.py +0 -0
  111. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/opensearch/__init__.py +0 -0
  112. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/opensearch/source.py +0 -0
  113. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/oracle/__init__.py +0 -0
  114. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/oracle/source.py +0 -0
  115. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/postgresql/__init__.py +0 -0
  116. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/postgresql/source.py +0 -0
  117. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/powerbi/__init__.py +0 -0
  118. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/powerbi/source.py +0 -0
  119. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/recipe_normalizer.py +0 -0
  120. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/s3_client.py +0 -0
  121. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/s3_compatible_storage/README.md +0 -0
  122. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/s3_compatible_storage/__init__.py +0 -0
  123. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/search_engine_base.py +0 -0
  124. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/servicedesk/__init__.py +0 -0
  125. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/servicedesk/source.py +0 -0
  126. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/slack/__init__.py +0 -0
  127. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/snowflake/__init__.py +0 -0
  128. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/snowflake/source.py +0 -0
  129. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/sqlite/__init__.py +0 -0
  130. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/sqlite/source.py +0 -0
  131. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/tableau/__init__.py +0 -0
  132. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/tableau/source.py +0 -0
  133. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/tabular_base.py +0 -0
  134. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/tabular_utils.py +0 -0
  135. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/wordpress/__init__.py +0 -0
  136. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/wordpress/source.py +0 -0
  137. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/youtube/__init__.py +0 -0
  138. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/telemetry.py +0 -0
  139. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/__init__.py +0 -0
  140. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/content_extraction.py +0 -0
  141. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/dependency_groups.py +0 -0
  142. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/embedded_images.py +0 -0
  143. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/file_metadata.py +0 -0
  144. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/file_parser.py +0 -0
  145. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/file_to_images.py +0 -0
  146. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/hashing.py +0 -0
  147. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/resources.py +0 -0
  148. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/transcription.py +0 -0
  149. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/uv_sync.py +0 -0
  150. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/validation.py +0 -0
  151. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/__init__.py +0 -0
  152. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/_lakehouse_fakes.py +0 -0
  153. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/conftest.py +0 -0
  154. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/__init__.py +0 -0
  155. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/broken_links/test_broken_links_detector.py +0 -0
  156. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/conftest.py +0 -0
  157. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/content/__init__.py +0 -0
  158. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/__init__.py +0 -0
  159. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/conftest.py +0 -0
  160. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/test_invoice_extraction.py +0 -0
  161. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/test_llm_runner.py +0 -0
  162. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/test_pipeline_integration.py +0 -0
  163. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/test_regex_runner.py +0 -0
  164. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/test_transformer_runners.py +0 -0
  165. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/pii/__init__.py +0 -0
  166. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/pii/conftest.py +0 -0
  167. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/pii/sample_invoice.pdf +0 -0
  168. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/pii/test_pii_detector.py +0 -0
  169. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/pii/test_pii_detector_extended.py +0 -0
  170. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/secrets/__init__.py +0 -0
  171. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/secrets/test_secrets_detector.py +0 -0
  172. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/secrets/test_secrets_detector_extended.py +0 -0
  173. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_base_detector.py +0 -0
  174. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_custom_detector_examples_runtime.py +0 -0
  175. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_detector_catalog_commercial.py +0 -0
  176. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_detector_pipeline_types.py +0 -0
  177. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_detector_schema_examples.py +0 -0
  178. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_detector_types.py +0 -0
  179. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_phase2_detectors.py +0 -0
  180. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_registry.py +0 -0
  181. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/threat/__init__.py +0 -0
  182. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/threat/test_code_security_detector.py +0 -0
  183. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/threat/test_yara_detector.py +0 -0
  184. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/integration/test_wordpress_broken_links_detector.py +0 -0
  185. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/integration/test_wordpress_links_assets.py +0 -0
  186. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/pipeline/test_detector_pipeline.py +0 -0
  187. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/pipeline/test_worker_pool.py +0 -0
  188. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_assets_metadata_catalog.py +0 -0
  189. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_base_source_attachment.py +0 -0
  190. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_base_source_sampling.py +0 -0
  191. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_config.py +0 -0
  192. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_confluence_source.py +0 -0
  193. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_custom_extractor.py +0 -0
  194. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_databricks_source.py +0 -0
  195. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_delta_lake_source.py +0 -0
  196. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_dependency_groups.py +0 -0
  197. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_elasticsearch_source.py +0 -0
  198. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_email_source.py +0 -0
  199. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_hashing.py +0 -0
  200. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_hive_source.py +0 -0
  201. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_iceberg_source.py +0 -0
  202. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_jira_source.py +0 -0
  203. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_meilisearch_source.py +0 -0
  204. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_mongodb_source.py +0 -0
  205. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_mssql_source.py +0 -0
  206. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_mysql_source.py +0 -0
  207. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_neo4j_source.py +0 -0
  208. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_notion_source.py +0 -0
  209. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_opensearch_source.py +0 -0
  210. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_oracle_source.py +0 -0
  211. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_outputs.py +0 -0
  212. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_postgresql_source.py +0 -0
  213. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_powerbi_source.py +0 -0
  214. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_recipe_normalizer.py +0 -0
  215. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_sampling_automatic.py +0 -0
  216. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_sandbox_runner.py +0 -0
  217. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_servicedesk_source.py +0 -0
  218. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_slack_source.py +0 -0
  219. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_snowflake_source.py +0 -0
  220. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_source_dependency_groups.py +0 -0
  221. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_sqlite_source.py +0 -0
  222. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_tableau_source.py +0 -0
  223. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_tabular_automatic_sampling.py +0 -0
  224. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_tabular_utils.py +0 -0
  225. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_uv_sync.py +0 -0
  226. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_wordpress_source.py +0 -0
  227. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_youtube_source.py +0 -0
  228. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_youtube_source_integration.py +0 -0
  229. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_content_extraction.py +0 -0
  230. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_embedded_images.py +0 -0
  231. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_file_metadata.py +0 -0
  232. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_file_parser.py +0 -0
  233. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_file_to_images.py +0 -0
  234. {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_transcription.py +0 -0
@@ -1,3 +1,3 @@
1
1
  $ uv sync
2
- Resolved 276 packages in 177ms
2
+ Resolved 276 packages in 193ms
3
3
  Checked 51 packages in 1ms
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: classifyre-cli
3
- Version: 0.4.45
3
+ Version: 0.4.47
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@classifyre/cli",
3
- "version": "0.4.45",
3
+ "version": "0.4.47",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "build": "uv sync",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "classifyre-cli"
3
- version = "0.4.45"
3
+ version = "0.4.47"
4
4
  description = "Classifyre CLI — scan and classify unstructured data sources"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -51,6 +51,7 @@ class AssetType(StrEnum):
51
51
  ELASTICSEARCH = 'ELASTICSEARCH'
52
52
  OPENSEARCH = 'OPENSEARCH'
53
53
  MEILISEARCH = 'MEILISEARCH'
54
+ LOCAL_FOLDER = 'LOCAL_FOLDER'
54
55
 
55
56
 
56
57
  class DetectorType(StrEnum):
@@ -355,6 +356,7 @@ class Type(StrEnum):
355
356
  ELASTICSEARCH = 'ELASTICSEARCH'
356
357
  OPENSEARCH = 'OPENSEARCH'
357
358
  MEILISEARCH = 'MEILISEARCH'
359
+ LOCAL_FOLDER = 'LOCAL_FOLDER'
358
360
 
359
361
 
360
362
  class YouTubeRequired(BaseModel):
@@ -644,6 +646,64 @@ class S3CompatibleStorageOptional(BaseModel):
644
646
  scope: ObjectStorageOptionalScope | None = None
645
647
 
646
648
 
649
+ class LocalFolderRequired(BaseModel):
650
+ model_config = ConfigDict(
651
+ extra='forbid',
652
+ )
653
+ path: str = Field(
654
+ ...,
655
+ description='Absolute path of the local folder to scan (for example, /Users/jane/Documents)',
656
+ )
657
+
658
+
659
+ class LocalFolderMasked(BaseModel):
660
+ """
661
+ Local folder scans run with the desktop application's filesystem permissions; no credentials are required.
662
+ """
663
+
664
+ model_config = ConfigDict(
665
+ extra='forbid',
666
+ )
667
+
668
+
669
+ class LocalFolderOptionalTraversal(BaseModel):
670
+ """
671
+ Directory traversal controls.
672
+ """
673
+
674
+ model_config = ConfigDict(
675
+ extra='forbid',
676
+ )
677
+ follow_symlinks: bool | None = Field(
678
+ False,
679
+ description='Follow symbolic links while traversing (loops are not detected; leave off unless needed)',
680
+ )
681
+ include_hidden: bool | None = Field(
682
+ False,
683
+ description='Include hidden files and dot-directories (names starting with a dot)',
684
+ )
685
+ max_depth: int | None = Field(
686
+ None,
687
+ description='Maximum directory depth below the selected folder (unset scans all levels)',
688
+ ge=1,
689
+ le=64,
690
+ )
691
+ max_file_bytes: int | None = Field(
692
+ 10485760,
693
+ description='Maximum bytes read per file for MIME detection and text extraction',
694
+ ge=1024,
695
+ le=104857600,
696
+ )
697
+
698
+
699
+ class LocalFolderOptional(BaseModel):
700
+ model_config = ConfigDict(
701
+ extra='forbid',
702
+ )
703
+ traversal: LocalFolderOptionalTraversal | None = None
704
+ scope: ObjectStorageOptionalScope | None = None
705
+
706
+
647
707
  class AzureBlobStorageRequired(BaseModel):
648
708
  model_config = ConfigDict(
649
709
  extra='forbid',
@@ -2100,6 +2160,24 @@ class S3CompatibleStorageInput(CoreInput):
2100
2160
  resources: ResourceOverrides | None = None
2101
2161
 
2102
2162
 
2163
+ class LocalFolderInput(CoreInput):
2164
+ type: Literal['LOCAL_FOLDER'] | None = Field(
2165
+ None, description='Type of the asset or source'
2166
+ )
2167
+ required: LocalFolderRequired
2168
+ masked: LocalFolderMasked | None = None
2169
+ optional: LocalFolderOptional | None = None
2170
+ detectors: list[Detector] | None = Field(
2171
+ None, description='Detectors to run on ingested content'
2172
+ )
2173
+ custom_detectors: list[CustomDetectorSelection] | None = Field(
2174
+ None,
2175
+ description='Reusable custom detector IDs selected from the custom detector catalog.',
2176
+ )
2177
+ sampling: SamplingConfig
2178
+ resources: ResourceOverrides | None = None
2179
+
2180
+
2103
2181
  class AzureBlobStorageInput(CoreInput):
2104
2182
  type: Literal['AZURE_BLOB_STORAGE'] | None = Field(
2105
2183
  None, description='Type of the asset or source'
@@ -2531,7 +2609,7 @@ class ConfluenceOptionalConnection(BaseModel):
2531
2609
  )
2532
2610
 
2533
2611
 
2534
- class Type18(StrEnum):
2612
+ class Type19(StrEnum):
2535
2613
  """
2536
2614
  Filter spaces by space type
2537
2615
  """
@@ -2568,7 +2646,7 @@ class ConfluenceOptionalScopeSpaces(BaseModel):
2568
2646
  keys: list[str] | None = Field(
2569
2647
  None, description='Filter spaces by keys (up to 250)', max_length=250
2570
2648
  )
2571
- type: Type18 | None = Field(None, description='Filter spaces by space type')
2649
+ type: Type19 | None = Field(None, description='Filter spaces by space type')
2572
2650
  status: Status | None = Field(None, description='Filter spaces by status')
2573
2651
  labels: list[str] | None = Field(
2574
2652
  None,
@@ -2834,7 +2912,7 @@ class ServiceDeskOptional(BaseModel):
2834
2912
  content: ServiceDeskOptionalContent | None = None
2835
2913
 
2836
2914
 
2837
- class Type19(StrEnum):
2915
+ class Type20(StrEnum):
2838
2916
  """
2839
2917
  Type of the asset or source
2840
2918
  """
@@ -2868,6 +2946,7 @@ class Type19(StrEnum):
2868
2946
  ELASTICSEARCH = 'ELASTICSEARCH'
2869
2947
  OPENSEARCH = 'OPENSEARCH'
2870
2948
  MEILISEARCH = 'MEILISEARCH'
2949
+ LOCAL_FOLDER = 'LOCAL_FOLDER'
2871
2950
 
2872
2951
 
2873
2952
  class ConfluenceInput(CoreInput):
@@ -3682,6 +3761,7 @@ class SourceInput(
3682
3761
  RootModel[
3683
3762
  SlackInput
3684
3763
  | S3CompatibleStorageInput
3764
+ | LocalFolderInput
3685
3765
  | AzureBlobStorageInput
3686
3766
  | GoogleCloudStorageInput
3687
3767
  | PostgreSQLInput
@@ -3714,6 +3794,7 @@ class SourceInput(
3714
3794
  root: (
3715
3795
  SlackInput
3716
3796
  | S3CompatibleStorageInput
3797
+ | LocalFolderInput
3717
3798
  | AzureBlobStorageInput
3718
3799
  | GoogleCloudStorageInput
3719
3800
  | PostgreSQLInput
@@ -111,12 +111,28 @@ class AzureBlobStorageSource(ObjectStorageSourceBase):
111
111
 
112
112
  def _download_object(self, ref: ObjectRef) -> tuple[bytes, str | None]:
113
113
  blob_service_client = self._client()
114
- container_client = blob_service_client.get_container_client(self._required_container())
114
+ container = self._required_container()
115
+ container_client = blob_service_client.get_container_client(container)
115
116
  blob_client = container_client.get_blob_client(ref.key)
116
117
 
117
118
  timeout = self._request_timeout_seconds()
118
- downloader = blob_client.download_blob(offset=0, length=None, timeout=timeout)
119
+ max_bytes = self._max_object_bytes()
120
+ # Ranged download: fetch only the capped prefix instead of the whole blob.
121
+ downloader = blob_client.download_blob(offset=0, length=max_bytes, timeout=timeout)
119
122
  file_bytes = downloader.readall()
123
+
124
+ if len(file_bytes) > max_bytes:
125
+ file_bytes = file_bytes[:max_bytes]
126
+ if ref.size > max_bytes:
127
+ logger.warning(
128
+ "Truncated %s/%s/%s to %d of %d bytes for content extraction",
129
+ self._required_account_url(),
130
+ container,
131
+ ref.key,
132
+ max_bytes,
133
+ ref.size,
134
+ )
135
+
120
136
  return file_bytes, ref.content_type_hint
121
137
 
122
138
  def _external_url(self, key: str) -> str:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import logging
4
5
  from collections.abc import Iterator
5
6
  from typing import Any
6
7
 
@@ -8,6 +9,8 @@ from ...models.generated_input import GoogleCloudStorageInput
8
9
  from ..dependencies import require_module
9
10
  from ..object_storage.base import ObjectRef, ObjectStorageSourceBase
10
11
 
12
+ logger = logging.getLogger(__name__)
13
+
11
14
 
12
15
  class GoogleCloudStorageSource(ObjectStorageSourceBase):
13
16
  source_type = "google_cloud_storage"
@@ -97,11 +100,26 @@ class GoogleCloudStorageSource(ObjectStorageSourceBase):
97
100
 
98
101
  def _download_object(self, ref: ObjectRef) -> tuple[bytes, str | None]:
99
102
  client = self._client()
100
- bucket = client.bucket(self._required_bucket())
103
+ bucket_name = self._required_bucket()
104
+ bucket = client.bucket(bucket_name)
101
105
  blob = bucket.blob(ref.key)
102
106
 
103
107
  timeout = self._request_timeout_seconds()
104
- file_bytes = blob.download_as_bytes(timeout=timeout)
108
+ max_bytes = self._max_object_bytes()
109
+ # Ranged download: fetch only the capped prefix instead of the whole blob.
110
+ file_bytes = blob.download_as_bytes(start=0, end=max_bytes - 1, timeout=timeout)
111
+
112
+ if len(file_bytes) > max_bytes:
113
+ file_bytes = file_bytes[:max_bytes]
114
+ if ref.size > max_bytes:
115
+ logger.warning(
116
+ "Truncated gs://%s/%s to %d of %d bytes for content extraction",
117
+ bucket_name,
118
+ ref.key,
119
+ max_bytes,
120
+ ref.size,
121
+ )
122
+
105
123
  return file_bytes, ref.content_type_hint
106
124
 
107
125
  def _external_url(self, key: str) -> str:
@@ -8,7 +8,11 @@ Sampling strategies map to consumer positioning:
8
8
 
9
9
  * ``LATEST`` — start near the tail of each partition (newest messages).
10
10
  * ``RANDOM`` — start at a random offset within each partition.
11
- * ``AUTOMATIC`` / ``ALL`` — start at the earliest retained offset.
11
+ * ``ALL`` — start at the earliest retained offset.
12
+ * ``AUTOMATIC`` — resume each partition from a saved per-partition cursor
13
+ (keyed ``"{topic}:{partition}"``), advancing it each run. Once a
14
+ partition's cursor catches up to the high watermark it wraps back to the
15
+ low watermark so the next run re-ingests from the start.
12
16
 
13
17
  All strategies read up to ``sampling.rows_per_page`` messages per topic.
14
18
  """
@@ -302,14 +306,56 @@ class KafkaSource(BaseSource):
302
306
  return max(low, high - per)
303
307
  if strategy == SamplingStrategy.RANDOM:
304
308
  return random.randint(low, max(low, high - per))
305
- # AUTOMATIC / ALL: read from the earliest retained offset.
309
+ # ALL: read from the earliest retained offset.
306
310
  return low
307
311
 
312
+ def _automatic_start_offset(self, key: str, low: int, high: int) -> int:
313
+ """Resume offset for AUTOMATIC sampling, clamped to the retained range.
314
+
315
+ Retention may have deleted the previously saved offset (or this may be
316
+ the first run), in which case we fall back to the earliest retained
317
+ offset.
318
+ """
319
+ saved = self.automatic_offset(key)
320
+ if saved < low or saved > high:
321
+ return low
322
+ return saved
323
+
324
+ def _record_automatic_cursors(
325
+ self,
326
+ topic: str,
327
+ messages: list[dict[str, Any]],
328
+ starts: dict[int, int],
329
+ watermarks: dict[int, tuple[int, int]],
330
+ ) -> None:
331
+ """Advance the per-partition AUTOMATIC cursor after a consume pass.
332
+
333
+ Partitions that yielded messages resume from one past the highest
334
+ offset consumed; partitions that were assigned but yielded nothing
335
+ keep their (already-clamped) start offset. A partition that has
336
+ caught up to its high watermark wraps back to the low watermark so
337
+ the next run re-ingests from the start instead of stalling forever.
338
+ """
339
+ consumed_next: dict[int, int] = {}
340
+ for message in messages:
341
+ partition_id = int(message["partition"])
342
+ next_offset = int(message["offset"]) + 1
343
+ if next_offset > consumed_next.get(partition_id, -1):
344
+ consumed_next[partition_id] = next_offset
345
+
346
+ for partition_id, (low, high) in watermarks.items():
347
+ next_offset = consumed_next.get(partition_id, starts[partition_id])
348
+ if next_offset >= high:
349
+ next_offset = low # fully caught up: wrap for the next run
350
+ self._record_cursor_key(f"{topic}:{partition_id}", next_offset)
351
+
308
352
  def _consume(self, topic: str, max_count: int) -> list[dict[str, Any]]:
309
353
  strategy = self._sampling().strategy
310
354
  consumer = self._make_consumer()
311
355
  timeout = self._request_timeout_seconds()
312
356
  out: list[dict[str, Any]] = []
357
+ starts: dict[int, int] = {}
358
+ watermarks: dict[int, tuple[int, int]] = {}
313
359
  try:
314
360
  metadata = self._cluster_metadata(consumer, topic)
315
361
  topic_meta = metadata.topics.get(topic)
@@ -325,9 +371,17 @@ class KafkaSource(BaseSource):
325
371
  except Exception as exc:
326
372
  logger.debug("Watermark lookup failed for %s[%s]: %s", topic, partition_id, exc)
327
373
  continue
374
+ low, high = int(low), int(high)
328
375
  if high <= low:
329
376
  continue # empty partition
330
- tp.offset = self._start_offset(strategy, int(low), int(high), per)
377
+ if strategy == SamplingStrategy.AUTOMATIC:
378
+ key = f"{topic}:{partition_id}"
379
+ start = self._automatic_start_offset(key, low, high)
380
+ starts[partition_id] = start
381
+ watermarks[partition_id] = (low, high)
382
+ else:
383
+ start = self._start_offset(strategy, low, high, per)
384
+ tp.offset = start
331
385
  assignments.append(tp)
332
386
  if not assignments:
333
387
  return out
@@ -357,6 +411,10 @@ class KafkaSource(BaseSource):
357
411
  break
358
412
  finally:
359
413
  consumer.close()
414
+
415
+ if strategy == SamplingStrategy.AUTOMATIC and watermarks:
416
+ self._record_automatic_cursors(topic, out, starts, watermarks)
417
+
360
418
  return out
361
419
 
362
420
  def _format_messages(
@@ -0,0 +1,3 @@
1
+ from .source import LocalFolderSource
2
+
3
+ __all__ = ["LocalFolderSource"]
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from collections.abc import Iterator
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+
11
+ from ...models.generated_input import LocalFolderInput
12
+ from ..object_storage.base import ObjectRef, ObjectStorageSourceBase
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class LocalFolderSource(ObjectStorageSourceBase):
18
+ """Scans a folder on the local filesystem (desktop application deployments).
19
+
20
+ Reuses the object-storage pipeline: each file becomes an ObjectRef whose key
21
+ is the path relative to the scanned root, so sampling, MIME resolution, text
22
+ extraction, file metadata, and embedded-image child assets all come from
23
+ ObjectStorageSourceBase.
24
+ """
25
+
26
+ source_type = "local_folder"
27
+ provider_label = "LOCAL_FOLDER"
28
+ input_model = LocalFolderInput
29
+
30
+ DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024
31
+
32
+ _resolved_root: Path | None = None
33
+
34
+ def _root(self) -> Path:
35
+ if self._resolved_root is not None:
36
+ return self._resolved_root
37
+ raw = str(self.config.required.path).strip()
38
+ if not raw:
39
+ raise ValueError("required.path must be set")
40
+ root = Path(raw).expanduser()
41
+ if not root.is_absolute():
42
+ raise ValueError(f"required.path must be an absolute path, got: {raw}")
43
+ if not root.exists():
44
+ raise ValueError(f"Folder does not exist: {root}")
45
+ if not root.is_dir():
46
+ raise ValueError(f"Path is not a folder: {root}")
47
+ self._resolved_root = root.resolve()
48
+ return self._resolved_root
49
+
50
+ def _traversal_option(self, key: str, default: Any = None) -> Any:
51
+ optional = self.config.optional
52
+ traversal = getattr(optional, "traversal", None) if optional else None
53
+ if traversal is not None:
54
+ value = getattr(traversal, key, None)
55
+ if value is not None:
56
+ return value
57
+ return default
58
+
59
+ def _follow_symlinks(self) -> bool:
60
+ return bool(self._traversal_option("follow_symlinks", False))
61
+
62
+ def _include_hidden(self) -> bool:
63
+ return bool(self._traversal_option("include_hidden", False))
64
+
65
+ def _max_depth(self) -> int | None:
66
+ value = self._traversal_option("max_depth")
67
+ try:
68
+ return int(value) if value is not None else None
69
+ except (TypeError, ValueError):
70
+ return None
71
+
72
+ def _max_file_bytes(self) -> int:
73
+ value = self._traversal_option("max_file_bytes", self.DEFAULT_MAX_FILE_BYTES)
74
+ try:
75
+ parsed = int(value)
76
+ except (TypeError, ValueError):
77
+ return self.DEFAULT_MAX_FILE_BYTES
78
+ return max(parsed, 1024)
79
+
80
+ def _is_hidden(self, name: str) -> bool:
81
+ return name.startswith(".")
82
+
83
+ def _walk(self, directory: Path, depth: int) -> Iterator[ObjectRef]:
84
+ max_depth = self._max_depth()
85
+ follow_symlinks = self._follow_symlinks()
86
+ include_hidden = self._include_hidden()
87
+
88
+ try:
89
+ entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
90
+ except OSError as exc:
91
+ logger.warning("Skipping unreadable directory %s: %s", directory, exc)
92
+ return
93
+
94
+ for entry in entries:
95
+ if not include_hidden and self._is_hidden(entry.name):
96
+ continue
97
+ try:
98
+ if entry.is_dir(follow_symlinks=follow_symlinks):
99
+ if max_depth is None or depth < max_depth:
100
+ yield from self._walk(Path(entry.path), depth + 1)
101
+ continue
102
+ if not entry.is_file(follow_symlinks=follow_symlinks):
103
+ continue
104
+ stat = entry.stat(follow_symlinks=follow_symlinks)
105
+ except OSError as exc:
106
+ logger.warning("Skipping unreadable entry %s: %s", entry.path, exc)
107
+ continue
108
+
109
+ key = Path(entry.path).relative_to(self._root()).as_posix()
110
+ if not self._object_matches_extension_filters(key):
111
+ continue
112
+ prefix = self._prefix()
113
+ if prefix and not key.startswith(prefix):
114
+ continue
115
+ if stat.st_size == 0 and not self._include_empty_objects():
116
+ continue
117
+
118
+ yield ObjectRef(
119
+ key=key,
120
+ size=stat.st_size,
121
+ last_modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
122
+ # Change signature: mtime+size, so edits produce a new checksum
123
+ # without hashing file bytes during discovery.
124
+ etag=f"{stat.st_mtime_ns:x}-{stat.st_size:x}",
125
+ )
126
+
127
+ def _list_objects(self) -> Iterator[ObjectRef]:
128
+ yield from self._walk(self._root(), depth=0)
129
+
130
+ def _download_object(self, ref: ObjectRef) -> tuple[bytes, str | None]:
131
+ file_path = self._root() / ref.key
132
+ max_bytes = self._max_file_bytes()
133
+ with open(file_path, "rb") as handle:
134
+ file_bytes = handle.read(max_bytes)
135
+ if ref.size > max_bytes:
136
+ logger.info(
137
+ "Truncated %s to %d of %d bytes for content extraction",
138
+ file_path,
139
+ max_bytes,
140
+ ref.size,
141
+ )
142
+ return file_bytes, None
143
+
144
+ def _external_url(self, key: str) -> str:
145
+ absolute = (self._root() / key).as_posix()
146
+ return f"file://{quote(absolute)}"
@@ -211,6 +211,20 @@ class ObjectStorageSourceBase(BaseSource, ABC):
211
211
  def _include_content_preview(self) -> bool:
212
212
  return bool(self._scope_option("include_content_preview", True))
213
213
 
214
+ # Shared cap across the object-storage family (S3-compatible, GCS, Azure Blob)
215
+ # so a single huge object can't OOM the scan pod. Mirrors local_folder's
216
+ # DEFAULT_MAX_FILE_BYTES guard, but sourced from each provider's own
217
+ # optional.connection.max_object_bytes field (schema default 5MB) when set.
218
+ DEFAULT_MAX_OBJECT_BYTES = 5 * 1024 * 1024
219
+
220
+ def _max_object_bytes(self) -> int:
221
+ value = self._connection_option("max_object_bytes", self.DEFAULT_MAX_OBJECT_BYTES)
222
+ try:
223
+ parsed = int(value)
224
+ except (TypeError, ValueError):
225
+ return self.DEFAULT_MAX_OBJECT_BYTES
226
+ return max(parsed, 1024)
227
+
214
228
  def _normalized_extension_filters(self, key: str) -> list[str]:
215
229
  values = self._scope_option(key, [])
216
230
  if not isinstance(values, list):
@@ -87,17 +87,30 @@ class S3CompatibleStorageSource(ObjectStorageSourceBase):
87
87
  def _download_object(self, ref: ObjectRef) -> tuple[bytes, str | None]:
88
88
  client = self._client()
89
89
  bucket = self._required_bucket()
90
+ max_bytes = self._max_object_bytes()
90
91
 
91
92
  response = client.get_object(Bucket=bucket, Key=ref.key)
92
93
  body = response["Body"]
93
94
  try:
94
- file_bytes = body.read()
95
+ # Read one byte past the cap so we can detect truncation without
96
+ # ever materializing the full (potentially huge) object body.
97
+ file_bytes = body.read(max_bytes + 1)
95
98
  finally:
96
99
  try:
97
100
  body.close()
98
101
  except Exception:
99
102
  logger.debug("Failed to close S3 response body")
100
103
 
104
+ if len(file_bytes) > max_bytes:
105
+ file_bytes = file_bytes[:max_bytes]
106
+ logger.warning(
107
+ "Truncated s3://%s/%s to %d of %d bytes for content extraction",
108
+ bucket,
109
+ ref.key,
110
+ max_bytes,
111
+ ref.size,
112
+ )
113
+
101
114
  content_type = response.get("ContentType")
102
115
  return file_bytes, str(content_type) if content_type else None
103
116
 
@@ -322,10 +322,9 @@ class SlackSource(BaseSource):
322
322
  is_automatic = strategy == SamplingStrategy.AUTOMATIC
323
323
  if strategy == SamplingStrategy.ALL:
324
324
  max_total: int | None = None
325
- elif is_automatic:
326
- max_total = int(sampling.rows_per_page or 100)
327
325
  else:
328
- max_total = 100
326
+ # AUTOMATIC / LATEST / RANDOM all honor the configured window size.
327
+ max_total = int(sampling.rows_per_page or 100)
329
328
  oldest = self._normalize_ts(time_range_options.oldest)
330
329
  latest = self._normalize_ts(time_range_options.latest)
331
330
  batch_size = min(int(ingestion_options.batch_size or 200), 200)
@@ -461,11 +461,11 @@ class YouTubeSource(BaseSource):
461
461
  if transcript.available_languages:
462
462
  asset_metadata["caption_tracks"] = transcript.available_languages
463
463
 
464
+ # Checksum must only reflect content changes: view/like counters move on
465
+ # every re-scan and would flip UNCHANGED assets to UPDATED spuriously.
464
466
  checksum_data = {
465
467
  "video_id": video_id,
466
468
  "title": title,
467
- "view_count": asset_metadata.get("view_count"),
468
- "like_count": asset_metadata.get("like_count"),
469
469
  "transcript_available": asset_metadata["transcript_available"],
470
470
  }
471
471
 
@@ -80,3 +80,74 @@ def test_azure_blob_storage_external_url():
80
80
  source._external_url("folder/report.csv")
81
81
  == "https://acme.blob.core.windows.net/documents/folder/report.csv"
82
82
  )
83
+
84
+
85
+ class _FakeDownloader:
86
+ def __init__(self, data: bytes) -> None:
87
+ self._data = data
88
+
89
+ def readall(self) -> bytes:
90
+ return self._data
91
+
92
+
93
+ class _FakeBlobClient:
94
+ def __init__(self, data: bytes) -> None:
95
+ self._data = data
96
+
97
+ def download_blob(
98
+ self, offset: int = 0, length: int | None = None, timeout: float | None = None
99
+ ) -> _FakeDownloader:
100
+ start = offset or 0
101
+ end = len(self._data) if length is None else start + length
102
+ return _FakeDownloader(self._data[start:end])
103
+
104
+
105
+ class _FakeContainerClient:
106
+ def __init__(self, data: bytes) -> None:
107
+ self._data = data
108
+
109
+ def get_blob_client(self, key: str) -> _FakeBlobClient:
110
+ return _FakeBlobClient(self._data)
111
+
112
+
113
+ class _FakeAzureClient:
114
+ def __init__(self, data: bytes) -> None:
115
+ self._data = data
116
+
117
+ def get_container_client(self, container: str) -> _FakeContainerClient:
118
+ return _FakeContainerClient(self._data)
119
+
120
+
121
+ def test_azure_blob_storage_download_object_truncates_oversized_blob(monkeypatch, caplog):
122
+ source = AzureBlobStorageSource(
123
+ {
124
+ **_recipe(),
125
+ "optional": {
126
+ "connection": {"max_object_bytes": 1024},
127
+ "scope": {"include_content_preview": True},
128
+ },
129
+ }
130
+ )
131
+ big_bytes = b"z" * 3000
132
+ monkeypatch.setattr(source, "_client", lambda: _FakeAzureClient(big_bytes))
133
+ ref = _ref("exports/big.bin", size=len(big_bytes))
134
+
135
+ with caplog.at_level("WARNING"):
136
+ file_bytes, _content_type = source._download_object(ref)
137
+
138
+ assert len(file_bytes) == 1024
139
+ assert any("Truncated" in record.message for record in caplog.records)
140
+
141
+
142
+ def test_azure_blob_storage_download_object_leaves_normal_blob_unchanged(monkeypatch, caplog):
143
+ source = AzureBlobStorageSource(_recipe())
144
+ small_bytes = b"hello azure"
145
+ monkeypatch.setattr(source, "_client", lambda: _FakeAzureClient(small_bytes))
146
+ ref = _ref("exports/small.txt", size=len(small_bytes))
147
+
148
+ with caplog.at_level("WARNING"):
149
+ file_bytes, content_type = source._download_object(ref)
150
+
151
+ assert file_bytes == small_bytes
152
+ assert content_type == "text/plain"
153
+ assert not any("Truncated" in record.message for record in caplog.records)