classifyre-cli 0.4.46__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 (235) hide show
  1. classifyre_cli-0.4.47/.turbo/turbo-build.log +3 -0
  2. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/PKG-INFO +1 -1
  3. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/package.json +1 -1
  4. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/pyproject.toml +1 -1
  5. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/azure_blob_storage/source.py +18 -2
  6. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/google_cloud_storage/source.py +20 -2
  7. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/kafka/source.py +61 -3
  8. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/object_storage/base.py +14 -0
  9. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/s3_compatible_storage/source.py +14 -1
  10. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/slack/source.py +2 -3
  11. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/youtube/source.py +2 -2
  12. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_azure_blob_storage_source.py +71 -0
  13. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_google_cloud_storage_source.py +65 -0
  14. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_kafka_source.py +108 -10
  15. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_s3_compatible_storage_source.py +65 -0
  16. classifyre_cli-0.4.47/tests/test_source_strategy_conformance.py +235 -0
  17. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/uv.lock +5 -5
  18. classifyre_cli-0.4.46/.turbo/turbo-build.log +0 -3
  19. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/.gitignore +0 -0
  20. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/.python-version +0 -0
  21. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/README.md +0 -0
  22. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/main.py +0 -0
  23. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/scripts/generate_models.py +0 -0
  24. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/__init__.py +0 -0
  25. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/config.py +0 -0
  26. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/__init__.py +0 -0
  27. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/base.py +0 -0
  28. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/broken_links/__init__.py +0 -0
  29. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/broken_links/detector.py +0 -0
  30. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/config.py +0 -0
  31. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/content/__init__.py +0 -0
  32. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/__init__.py +0 -0
  33. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/detector.py +0 -0
  34. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/extractor.py +0 -0
  35. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/runners/__init__.py +0 -0
  36. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_base.py +0 -0
  37. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_factory.py +0 -0
  38. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_feature_extraction.py +0 -0
  39. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_gliner2.py +0 -0
  40. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_image_classification.py +0 -0
  41. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_llm.py +0 -0
  42. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_object_detection.py +0 -0
  43. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_regex.py +0 -0
  44. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_text_classification.py +0 -0
  45. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/custom/trainer.py +0 -0
  46. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/dependencies.py +0 -0
  47. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/pii/__init__.py +0 -0
  48. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/pii/detector.py +0 -0
  49. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/secrets/__init__.py +0 -0
  50. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/secrets/detector.py +0 -0
  51. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/threat/__init__.py +0 -0
  52. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/threat/code_security_detector.py +0 -0
  53. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/detectors/threat/yara_detector.py +0 -0
  54. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/main.py +0 -0
  55. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/models/generated_detectors.py +0 -0
  56. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/models/generated_input.py +0 -0
  57. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/models/generated_single_asset_scan_results.py +0 -0
  58. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/outputs/__init__.py +0 -0
  59. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/outputs/base.py +0 -0
  60. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/outputs/console.py +0 -0
  61. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/outputs/factory.py +0 -0
  62. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/outputs/file.py +0 -0
  63. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/outputs/rest.py +0 -0
  64. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/pipeline/__init__.py +0 -0
  65. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/pipeline/content_provider.py +0 -0
  66. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/pipeline/detector_pipeline.py +0 -0
  67. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/pipeline/parsed_content_provider.py +0 -0
  68. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/pipeline/worker_pool.py +0 -0
  69. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sandbox/__init__.py +0 -0
  70. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sandbox/runner.py +0 -0
  71. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/__init__.py +0 -0
  72. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/asset_metadata.py +0 -0
  73. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/atlassian_common.py +0 -0
  74. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/azure_blob_storage/__init__.py +0 -0
  75. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/base.py +0 -0
  76. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/confluence/__init__.py +0 -0
  77. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/confluence/source.py +0 -0
  78. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/databricks/__init__.py +0 -0
  79. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/databricks/source.py +0 -0
  80. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/delta_lake/__init__.py +0 -0
  81. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/delta_lake/source.py +0 -0
  82. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/dependencies.py +0 -0
  83. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/elasticsearch/__init__.py +0 -0
  84. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/elasticsearch/source.py +0 -0
  85. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/email/__init__.py +0 -0
  86. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/email/source.py +0 -0
  87. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/google_cloud_storage/__init__.py +0 -0
  88. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/hive/__init__.py +0 -0
  89. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/hive/source.py +0 -0
  90. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/iceberg/__init__.py +0 -0
  91. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/iceberg/source.py +0 -0
  92. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/jira/__init__.py +0 -0
  93. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/jira/source.py +0 -0
  94. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/kafka/__init__.py +0 -0
  95. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/lakehouse_base.py +0 -0
  96. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/local_folder/__init__.py +0 -0
  97. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/local_folder/source.py +0 -0
  98. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/meilisearch/__init__.py +0 -0
  99. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/meilisearch/source.py +0 -0
  100. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/mongodb/__init__.py +0 -0
  101. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/mongodb/source.py +0 -0
  102. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/mssql/__init__.py +0 -0
  103. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/mssql/source.py +0 -0
  104. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/mysql/__init__.py +0 -0
  105. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/mysql/source.py +0 -0
  106. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/neo4j/__init__.py +0 -0
  107. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/neo4j/source.py +0 -0
  108. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/notion/__init__.py +0 -0
  109. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/notion/client.py +0 -0
  110. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/notion/source.py +0 -0
  111. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/opensearch/__init__.py +0 -0
  112. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/opensearch/source.py +0 -0
  113. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/oracle/__init__.py +0 -0
  114. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/oracle/source.py +0 -0
  115. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/postgresql/__init__.py +0 -0
  116. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/postgresql/source.py +0 -0
  117. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/powerbi/__init__.py +0 -0
  118. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/powerbi/source.py +0 -0
  119. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/recipe_normalizer.py +0 -0
  120. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/s3_client.py +0 -0
  121. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/s3_compatible_storage/README.md +0 -0
  122. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/s3_compatible_storage/__init__.py +0 -0
  123. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/search_engine_base.py +0 -0
  124. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/servicedesk/__init__.py +0 -0
  125. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/servicedesk/source.py +0 -0
  126. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/slack/__init__.py +0 -0
  127. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/snowflake/__init__.py +0 -0
  128. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/snowflake/source.py +0 -0
  129. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/sqlite/__init__.py +0 -0
  130. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/sqlite/source.py +0 -0
  131. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/tableau/__init__.py +0 -0
  132. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/tableau/source.py +0 -0
  133. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/tabular_base.py +0 -0
  134. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/tabular_utils.py +0 -0
  135. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/wordpress/__init__.py +0 -0
  136. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/wordpress/source.py +0 -0
  137. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/sources/youtube/__init__.py +0 -0
  138. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/telemetry.py +0 -0
  139. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/__init__.py +0 -0
  140. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/content_extraction.py +0 -0
  141. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/dependency_groups.py +0 -0
  142. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/embedded_images.py +0 -0
  143. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/file_metadata.py +0 -0
  144. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/file_parser.py +0 -0
  145. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/file_to_images.py +0 -0
  146. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/hashing.py +0 -0
  147. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/resources.py +0 -0
  148. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/transcription.py +0 -0
  149. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/uv_sync.py +0 -0
  150. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/src/utils/validation.py +0 -0
  151. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/__init__.py +0 -0
  152. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/_lakehouse_fakes.py +0 -0
  153. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/conftest.py +0 -0
  154. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/__init__.py +0 -0
  155. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/broken_links/test_broken_links_detector.py +0 -0
  156. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/conftest.py +0 -0
  157. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/content/__init__.py +0 -0
  158. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/custom/__init__.py +0 -0
  159. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/custom/conftest.py +0 -0
  160. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/custom/test_invoice_extraction.py +0 -0
  161. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/custom/test_llm_runner.py +0 -0
  162. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/custom/test_pipeline_integration.py +0 -0
  163. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/custom/test_regex_runner.py +0 -0
  164. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/custom/test_transformer_runners.py +0 -0
  165. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/pii/__init__.py +0 -0
  166. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/pii/conftest.py +0 -0
  167. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/pii/sample_invoice.pdf +0 -0
  168. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/pii/test_pii_detector.py +0 -0
  169. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/pii/test_pii_detector_extended.py +0 -0
  170. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/secrets/__init__.py +0 -0
  171. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/secrets/test_secrets_detector.py +0 -0
  172. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/secrets/test_secrets_detector_extended.py +0 -0
  173. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/test_base_detector.py +0 -0
  174. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/test_custom_detector_examples_runtime.py +0 -0
  175. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/test_detector_catalog_commercial.py +0 -0
  176. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/test_detector_pipeline_types.py +0 -0
  177. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/test_detector_schema_examples.py +0 -0
  178. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/test_detector_types.py +0 -0
  179. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/test_phase2_detectors.py +0 -0
  180. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/test_registry.py +0 -0
  181. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/threat/__init__.py +0 -0
  182. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/threat/test_code_security_detector.py +0 -0
  183. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/detectors/threat/test_yara_detector.py +0 -0
  184. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/integration/test_wordpress_broken_links_detector.py +0 -0
  185. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/integration/test_wordpress_links_assets.py +0 -0
  186. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/pipeline/test_detector_pipeline.py +0 -0
  187. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/pipeline/test_worker_pool.py +0 -0
  188. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_assets_metadata_catalog.py +0 -0
  189. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_base_source_attachment.py +0 -0
  190. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_base_source_sampling.py +0 -0
  191. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_config.py +0 -0
  192. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_confluence_source.py +0 -0
  193. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_custom_extractor.py +0 -0
  194. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_databricks_source.py +0 -0
  195. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_delta_lake_source.py +0 -0
  196. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_dependency_groups.py +0 -0
  197. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_elasticsearch_source.py +0 -0
  198. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_email_source.py +0 -0
  199. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_hashing.py +0 -0
  200. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_hive_source.py +0 -0
  201. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_iceberg_source.py +0 -0
  202. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_jira_source.py +0 -0
  203. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_local_folder_source.py +0 -0
  204. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_meilisearch_source.py +0 -0
  205. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_mongodb_source.py +0 -0
  206. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_mssql_source.py +0 -0
  207. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_mysql_source.py +0 -0
  208. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_neo4j_source.py +0 -0
  209. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_notion_source.py +0 -0
  210. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_opensearch_source.py +0 -0
  211. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_oracle_source.py +0 -0
  212. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_outputs.py +0 -0
  213. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_postgresql_source.py +0 -0
  214. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_powerbi_source.py +0 -0
  215. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_recipe_normalizer.py +0 -0
  216. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_sampling_automatic.py +0 -0
  217. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_sandbox_runner.py +0 -0
  218. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_servicedesk_source.py +0 -0
  219. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_slack_source.py +0 -0
  220. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_snowflake_source.py +0 -0
  221. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_source_dependency_groups.py +0 -0
  222. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_sqlite_source.py +0 -0
  223. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_tableau_source.py +0 -0
  224. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_tabular_automatic_sampling.py +0 -0
  225. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_tabular_utils.py +0 -0
  226. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_uv_sync.py +0 -0
  227. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_wordpress_source.py +0 -0
  228. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_youtube_source.py +0 -0
  229. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/test_youtube_source_integration.py +0 -0
  230. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/utils/test_content_extraction.py +0 -0
  231. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/utils/test_embedded_images.py +0 -0
  232. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/utils/test_file_metadata.py +0 -0
  233. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/utils/test_file_parser.py +0 -0
  234. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/utils/test_file_to_images.py +0 -0
  235. {classifyre_cli-0.4.46 → classifyre_cli-0.4.47}/tests/utils/test_transcription.py +0 -0
@@ -0,0 +1,3 @@
1
+ $ uv sync
2
+ Resolved 276 packages in 193ms
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.46
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.46",
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.46"
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"
@@ -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(
@@ -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)
@@ -71,3 +71,68 @@ async def test_google_cloud_storage_extract_uses_all_sampling(monkeypatch):
71
71
  assert [asset.name for asset in assets] == ["new.txt", "data.parquet"]
72
72
  assert assets[0].asset_type == OutputAssetType.TXT
73
73
  assert assets[1].asset_type == OutputAssetType.TABLE
74
+
75
+
76
+ class _FakeBlob:
77
+ def __init__(self, data: bytes) -> None:
78
+ self._data = data
79
+
80
+ def download_as_bytes(
81
+ self, start: int | None = None, end: int | None = None, timeout: float | None = None
82
+ ) -> bytes:
83
+ if start is None and end is None:
84
+ return self._data
85
+ range_start = start or 0
86
+ range_end = len(self._data) - 1 if end is None else end
87
+ return self._data[range_start : range_end + 1]
88
+
89
+
90
+ class _FakeBucket:
91
+ def __init__(self, data: bytes) -> None:
92
+ self._data = data
93
+
94
+ def blob(self, key: str) -> _FakeBlob:
95
+ return _FakeBlob(self._data)
96
+
97
+
98
+ class _FakeGcsClient:
99
+ def __init__(self, data: bytes) -> None:
100
+ self._data = data
101
+
102
+ def bucket(self, name: str) -> _FakeBucket:
103
+ return _FakeBucket(self._data)
104
+
105
+
106
+ def test_google_cloud_storage_download_object_truncates_oversized_blob(monkeypatch, caplog):
107
+ source = GoogleCloudStorageSource(
108
+ {
109
+ **_recipe(),
110
+ "optional": {
111
+ "connection": {"max_object_bytes": 1024},
112
+ "scope": {"include_content_preview": True},
113
+ },
114
+ }
115
+ )
116
+ big_bytes = b"y" * 3000
117
+ monkeypatch.setattr(source, "_client", lambda: _FakeGcsClient(big_bytes))
118
+ ref = _ref("exports/big.bin", days_ago=0, size=len(big_bytes))
119
+
120
+ with caplog.at_level("WARNING"):
121
+ file_bytes, _content_type = source._download_object(ref)
122
+
123
+ assert len(file_bytes) == 1024
124
+ assert any("Truncated" in record.message for record in caplog.records)
125
+
126
+
127
+ def test_google_cloud_storage_download_object_leaves_normal_blob_unchanged(monkeypatch, caplog):
128
+ source = GoogleCloudStorageSource(_recipe())
129
+ small_bytes = b"hello gcs"
130
+ monkeypatch.setattr(source, "_client", lambda: _FakeGcsClient(small_bytes))
131
+ ref = _ref("exports/small.txt", days_ago=0, size=len(small_bytes))
132
+
133
+ with caplog.at_level("WARNING"):
134
+ file_bytes, content_type = source._download_object(ref)
135
+
136
+ assert file_bytes == small_bytes
137
+ assert content_type == "text/plain"
138
+ assert not any("Truncated" in record.message for record in caplog.records)
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
4
+ import json
3
5
  from typing import Any
4
6
 
5
7
  import pytest
@@ -7,6 +9,12 @@ import pytest
7
9
  from src.models.generated_input import SamplingStrategy
8
10
  from src.sources.kafka.source import KafkaSource
9
11
 
12
+ CURSOR_ENV = "CLASSIFYRE_SAMPLING_CURSOR"
13
+
14
+
15
+ def _encode_cursor(cursor: dict[str, Any]) -> str:
16
+ return base64.b64encode(json.dumps(cursor).encode()).decode()
17
+
10
18
  # ── confluent-kafka fakes ────────────────────────────────────────────────
11
19
 
12
20
 
@@ -64,25 +72,33 @@ class _FakeConsumer:
64
72
  self._module = module
65
73
  self.conf = conf
66
74
  self.assigned: list[_TopicPartition] = []
67
- self._consumed = False
75
+ self._assigned_offsets: dict[int, int] = {}
76
+ self._pointer = 0
68
77
 
69
78
  def list_topics(self, topic: str | None = None, timeout: float = 0) -> _ClusterMetadata:
70
79
  if topic is not None:
71
80
  return _ClusterMetadata([topic] if topic in self._module.topic_names else [])
72
81
  return _ClusterMetadata(self._module.topic_names)
73
82
 
74
- def get_watermark_offsets(self, _tp: _TopicPartition, timeout: float = 0) -> tuple[int, int]:
75
- return (0, 100)
83
+ def get_watermark_offsets(self, tp: _TopicPartition, timeout: float = 0) -> tuple[int, int]:
84
+ return self._module.watermarks.get((tp.topic, tp.partition), (0, 100))
76
85
 
77
86
  def assign(self, tps: list[_TopicPartition]) -> None:
78
87
  self.assigned = tps
79
88
  self._module.last_assigned = tps
89
+ self._assigned_offsets = {tp.partition: tp.offset for tp in tps}
80
90
 
81
91
  def consume(self, num_messages: int, timeout: float = 0) -> list[_FakeMessage]:
82
- if self._consumed:
83
- return []
84
- self._consumed = True
85
- return self._module.messages[:num_messages]
92
+ assigned_partitions = set(self._assigned_offsets)
93
+ available = [
94
+ m
95
+ for m in self._module.messages
96
+ if m.partition() in assigned_partitions
97
+ and m.offset() >= self._assigned_offsets[m.partition()]
98
+ ]
99
+ batch = available[self._pointer : self._pointer + num_messages]
100
+ self._pointer += len(batch)
101
+ return batch
86
102
 
87
103
  def close(self) -> None:
88
104
  pass
@@ -126,6 +142,8 @@ class _FakeKafkaModule:
126
142
  self.ConfigResource = _ConfigResource
127
143
  self.last_consumer_conf: dict[str, Any] | None = None
128
144
  self.last_assigned: list[_TopicPartition] = []
145
+ # Optional per-(topic, partition) watermark override; defaults to (0, 100).
146
+ self.watermarks: dict[tuple[str, int], tuple[int, int]] = {}
129
147
 
130
148
  def Consumer(self, conf: dict[str, Any]) -> _FakeConsumer: # noqa: N802
131
149
  self.last_consumer_conf = conf
@@ -146,8 +164,11 @@ def _recipe(**overrides: Any) -> dict[str, Any]:
146
164
  return base
147
165
 
148
166
 
149
- def _messages(count: int) -> list[_FakeMessage]:
150
- return [_FakeMessage(0, i, f"k{i}".encode(), f"value-{i}".encode()) for i in range(count)]
167
+ def _messages(count: int, *, start: int = 0, partition: int = 0) -> list[_FakeMessage]:
168
+ return [
169
+ _FakeMessage(partition, start + i, f"k{start + i}".encode(), f"value-{start + i}".encode())
170
+ for i in range(count)
171
+ ]
151
172
 
152
173
 
153
174
  @pytest.fixture
@@ -186,7 +207,13 @@ async def test_kafka_extract_emits_topic_assets(_patch_kafka: _FakeKafkaModule)
186
207
  assert meta["cleanup_policy"] == "delete"
187
208
 
188
209
 
189
- async def test_kafka_fetch_content_samples_messages(_patch_kafka: _FakeKafkaModule) -> None:
210
+ async def test_kafka_fetch_content_samples_messages(
211
+ _patch_kafka: _FakeKafkaModule, monkeypatch: pytest.MonkeyPatch
212
+ ) -> None:
213
+ # Keep the random start offset within the range of fake messages available (0-11),
214
+ # and pin it deterministically so the sampled window is known.
215
+ _patch_kafka.watermarks[("payments", 0)] = (0, 12)
216
+ monkeypatch.setattr("src.sources.kafka.source.random.randint", lambda _a, _b: 0)
190
217
  src = KafkaSource(_recipe(sampling={"strategy": "RANDOM", "rows_per_page": 10}))
191
218
  assets = [a async for batch in src.extract_raw() for a in batch]
192
219
  result = await src.fetch_content(assets[0].hash)
@@ -249,3 +276,74 @@ def test_kafka_latest_strategy_assigns_tail_offsets(_patch_kafka: _FakeKafkaModu
249
276
  src._consume("payments", 10)
250
277
  assert len(module.last_assigned) == 1
251
278
  assert module.last_assigned[0].offset == 90
279
+
280
+
281
+ # ── AUTOMATIC sampling ───────────────────────────────────────────────────
282
+
283
+
284
+ def test_kafka_automatic_first_run_starts_at_low_and_records_cursor(
285
+ _patch_kafka: _FakeKafkaModule, monkeypatch: pytest.MonkeyPatch
286
+ ) -> None:
287
+ monkeypatch.delenv(CURSOR_ENV, raising=False)
288
+ module = _patch_kafka
289
+ module.watermarks[("payments", 0)] = (0, 100)
290
+ module.messages = _messages(12, start=0)
291
+
292
+ src = KafkaSource(_recipe(sampling={"strategy": "AUTOMATIC", "rows_per_page": 10}))
293
+ out = src._consume("payments", 5)
294
+
295
+ assert module.last_assigned[0].offset == 0 # first run: start at low
296
+ assert [m["offset"] for m in out] == [0, 1, 2, 3, 4]
297
+ # advanced past the highest offset consumed
298
+ assert src.current_sampling_cursor() == {"payments:0": 5}
299
+
300
+
301
+ def test_kafka_automatic_second_run_resumes_from_saved_cursor(
302
+ _patch_kafka: _FakeKafkaModule, monkeypatch: pytest.MonkeyPatch
303
+ ) -> None:
304
+ module = _patch_kafka
305
+ module.watermarks[("payments", 0)] = (0, 100)
306
+ module.messages = _messages(12, start=0)
307
+ monkeypatch.setenv(CURSOR_ENV, _encode_cursor({"payments:0": 5}))
308
+
309
+ src = KafkaSource(_recipe(sampling={"strategy": "AUTOMATIC", "rows_per_page": 10}))
310
+ out = src._consume("payments", 5)
311
+
312
+ assert module.last_assigned[0].offset == 5
313
+ assert [m["offset"] for m in out] == [5, 6, 7, 8, 9]
314
+ assert src.current_sampling_cursor() == {"payments:0": 10}
315
+
316
+
317
+ def test_kafka_automatic_wraps_to_low_when_caught_up(
318
+ _patch_kafka: _FakeKafkaModule, monkeypatch: pytest.MonkeyPatch
319
+ ) -> None:
320
+ module = _patch_kafka
321
+ module.watermarks[("payments", 0)] = (0, 10)
322
+ # Only two messages left before the high watermark.
323
+ module.messages = _messages(2, start=8)
324
+ monkeypatch.setenv(CURSOR_ENV, _encode_cursor({"payments:0": 8}))
325
+
326
+ src = KafkaSource(_recipe(sampling={"strategy": "AUTOMATIC", "rows_per_page": 10}))
327
+ out = src._consume("payments", 5)
328
+
329
+ assert module.last_assigned[0].offset == 8
330
+ assert [m["offset"] for m in out] == [8, 9]
331
+ # 9 + 1 == 10 == high watermark → wraps back to low (0) for the next run
332
+ assert src.current_sampling_cursor() == {"payments:0": 0}
333
+
334
+
335
+ def test_kafka_automatic_stale_cursor_below_low_clamps_to_low(
336
+ _patch_kafka: _FakeKafkaModule, monkeypatch: pytest.MonkeyPatch
337
+ ) -> None:
338
+ module = _patch_kafka
339
+ # Retention has advanced: earliest retained offset is now 20.
340
+ module.watermarks[("payments", 0)] = (20, 100)
341
+ module.messages = _messages(5, start=20)
342
+ monkeypatch.setenv(CURSOR_ENV, _encode_cursor({"payments:0": 5}))
343
+
344
+ src = KafkaSource(_recipe(sampling={"strategy": "AUTOMATIC", "rows_per_page": 10}))
345
+ out = src._consume("payments", 5)
346
+
347
+ assert module.last_assigned[0].offset == 20 # clamped up to low, not the stale 5
348
+ assert [m["offset"] for m in out] == [20, 21, 22, 23, 24]
349
+ assert src.current_sampling_cursor() == {"payments:0": 25}