classifyre-cli 0.7.0__py3-none-any.whl → 0.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: classifyre-cli
3
- Version: 0.7.0
3
+ Version: 0.7.2
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
  src/__init__.py,sha256=a9znVQMuKTEdH6nB8kUs7jMmPV1U6IFXDzCU5RnzxFo,23
2
2
  src/config.py,sha256=c4v6RbUQuWNIaCySaL5isNmBcUnYZimaHBW7Ki2InpU,2688
3
- src/main.py,sha256=NQzhDGS-_ycYdN_gfg_qK7es-I-h3DnBkFwwTWWYkXE,61287
3
+ src/main.py,sha256=lcc7d9rG0TQa5TcLadRqhH77bx5ulpqLyJ2dqQqHs-Y,61645
4
4
  src/telemetry.py,sha256=fbSmuJ_t-kSthfIh3E7ESVkloH22kzl8lQKY370YtuU,3089
5
5
  src/augmentation/__init__.py,sha256=pfb4zDjcyo62kHtvRl9QJ-xV28K0A_DTT_kJoF559Lc,147
6
6
  src/augmentation/contract.py,sha256=V0XQn7gk--vRSFTt7f5nC2fh8568KQR_oZ2hg3Tf20I,2716
@@ -53,7 +53,7 @@ src/file_evaluation/__init__.py,sha256=S2IF_CeZtxbfcVKcEamdyW1mZVb-fHAuWqt8QE999
53
53
  src/file_evaluation/asset_fixture.py,sha256=6fcEXlIxYYVE7xJpGtH28Zhmwjdh4ewxqfJlCJSOZ8Y,5192
54
54
  src/file_evaluation/runner.py,sha256=_hZ9ewrnnU-QkUmK19MErA57AZ06TQqAzncoeVc9eOM,17379
55
55
  src/graph/__init__.py,sha256=IQYUoqc3x5ZF49EBD_uOqyGF42R3JpQkmSZ9oN09ezA,609
56
- src/graph/edges.py,sha256=OIc795_PnSvRl_fRfGevsf0tBKP2zJWqUTcOc_Bakx4,18123
56
+ src/graph/edges.py,sha256=svktVn6M_aiiYnAPO2lLJJiyegoA65EAsgDX3vsHccM,18196
57
57
  src/models/generated_detectors.py,sha256=I-hHQ1iPM7NzWRE5DDamcWB0noNSUq44Lnubco_V3UA,73164
58
58
  src/models/generated_input.py,sha256=3_BI2n4NJ4Md9OcTZTeVYxT-bRSb8Q5pK6CFbNEuv_k,214174
59
59
  src/models/generated_single_asset_scan_results.py,sha256=zJ78x8GsjpiOBWG5aOQHHVBHdjQeosmCPQ2etwYxAv4,16508
@@ -77,7 +77,7 @@ src/outputs/base.py,sha256=uMc0_o2FhA9Y8wlwpg75ljHaXIkF-Yh_frZcPNZVcMo,4962
77
77
  src/outputs/console.py,sha256=OwPP7fXCWFML2zB4d8AxPXikanebI8CrixbgsS3CMGM,1803
78
78
  src/outputs/factory.py,sha256=ritKokn9SM6s51MSCPJhLKQMwhKF4OBRatsntGYi22w,5397
79
79
  src/outputs/file.py,sha256=6y585mxJS04MgTcMu5KPkJt_hm9qLjnbPEzD4MgrmI4,2594
80
- src/outputs/rest.py,sha256=-kzbkrsHrijFF9OaEvUvA29MBoQFyAp9q0kyT8k0W7g,28107
80
+ src/outputs/rest.py,sha256=o2e9SXxNIjX6y5dSGmyY-QjQbI1T7OjPXtfx_YG5d_Y,29004
81
81
  src/pipeline/__init__.py,sha256=BCWl3hwwLkmZc2vQ2fEyUdhis4TPK930HEgXTog8NjA,742
82
82
  src/pipeline/asset_payload_server.py,sha256=6LPEcybDk6gKYw6cSVXRKAHmZhkczwkjt3YgE6kN644,9773
83
83
  src/pipeline/content_provider.py,sha256=XiSOED7Lw0S663ybZo4iveQRceg5f51tdU9tyv2erSg,1078
@@ -93,13 +93,13 @@ src/pipeline/worker_pool.py,sha256=Yjr_2LXxV2nYNcXNN4gFRPK9KN15GP_IkkAzaplKaTc,1
93
93
  src/sources/__init__.py,sha256=wYksO_0IcvlGXLouWapKyqzL0jG0El4rDDGRfwa4Zqk,3350
94
94
  src/sources/asset_metadata.py,sha256=OnHwnUSlT1Ow_6Rz2SnJvZyJvaz00TsuQgAnXjTXsdA,6143
95
95
  src/sources/atlassian_common.py,sha256=kKriT1BdPwFq4PjqSm5BCb6UE5JaJckHQGObPyk95oM,11982
96
- src/sources/base.py,sha256=e1PhMOcs1STN1SeQD9RWQ8kNkml6o_njw5WEWKZNg_4,36927
96
+ src/sources/base.py,sha256=Brj7h9L_T9dQ6N6AyY0DrDD_C7dDKYUEmmjc8_SQOuk,42206
97
97
  src/sources/dependencies.py,sha256=ei5P3S1CxnNN-zKlChKwzyWEeAApYggPM_YoqrSNWaU,2699
98
98
  src/sources/lakehouse_base.py,sha256=c8xj5zzSw6GP_vWMT7aIZNYlsL3t2hkPJWzhYRQbWDc,17217
99
99
  src/sources/recipe_normalizer.py,sha256=tgZj_jYnT-67rlDiEF9AsQUS7EcxCvDUUsk2LO8RrRE,6207
100
100
  src/sources/s3_client.py,sha256=_v-JlCihlltbbI6dA-OeW4rAvglCKVmj4r8Ib1ViU_A,2368
101
101
  src/sources/search_engine_base.py,sha256=meQbpV2Si2T8tBe2DS7VvvpZZ7KVCn6cNqCYCwdwLUE,18308
102
- src/sources/tabular_base.py,sha256=wDAe-ydHnTQ1LKtlXstBUTjyNjmxmSfN_ZVgy37gLZU,55941
102
+ src/sources/tabular_base.py,sha256=C4CPq1Wz3a78uPRSF634EAVXUM9IGpF36_9-g0_DEUA,57586
103
103
  src/sources/tabular_utils.py,sha256=-kiULIyWbNELx9vhQF4O-xbEgkQurg3hGRLCnHHRjbs,7308
104
104
  src/sources/azure_blob_storage/__init__.py,sha256=EAsp7N2Vpzc_S-ownm3-ieXVWZSIVXPPNiSlDGCyenI,81
105
105
  src/sources/azure_blob_storage/source.py,sha256=PGKY-OEyO_AywVok36M6-Q8Whq2_yGIoRr7m-qNrtus,7144
@@ -108,9 +108,9 @@ src/sources/confluence/source.py,sha256=ngY_RtkHD0pLZbQokPUp580kOnCIl0c-3v_AJmW9
108
108
  src/sources/custom/__init__.py,sha256=LeFb8IS8gGSDVZ710S0GcmDeau31fvPHg3GroIzuU_4,61
109
109
  src/sources/custom/env.py,sha256=bO82OJVRQ5x8a1AK6jYh5UXS7CG9qodWrxxX6H9xPvU,2543
110
110
  src/sources/custom/runner.py,sha256=OSneHmlCNFkl2XNYaNx7X2s_Pt2l11-3lAmfcdAFMKg,15611
111
- src/sources/custom/source.py,sha256=hqBN5Tu9D5AT4Lm4qNfq0ctq6VT_a69Y8m49oIZ0GjM,50912
111
+ src/sources/custom/source.py,sha256=uHjY18mCBFrXrVI34T4d-l3YL9C9zE0p7uM_by6CFiQ,50925
112
112
  src/sources/databricks/__init__.py,sha256=yZZxIF-qhpt_W_zksFQoJGBqaIA40vQCyYLFSksGRFM,69
113
- src/sources/databricks/source.py,sha256=GfOOldIOIcGr9wRD8Qr9x6xxV8LIG8mJhbOOmdII2RQ,44161
113
+ src/sources/databricks/source.py,sha256=323oLatmMfq2-e3FKz7-VqPNB_lFLBZiai-9HS0S1yw,52048
114
114
  src/sources/delta_lake/__init__.py,sha256=KWZQy0l3sT5d7mfeDEbKlbOY1npMhC80dkQW6Ft42Bc,67
115
115
  src/sources/delta_lake/source.py,sha256=3RUn0sNULDmN9GStApnJxxrz04AOSHhAaLaP6TAXwjk,5466
116
116
  src/sources/dremio/__init__.py,sha256=qIDDLY7WK1lMIU2hGbLxRE_uPLN8-Ksb0FHuwmBZTlw,61
@@ -130,7 +130,7 @@ src/sources/google_cloud_storage/source.py,sha256=yyTh005smphxYdDigSIVQsqxf7qR9u
130
130
  src/sources/google_workspace/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
131
131
  src/sources/google_workspace/source.py,sha256=aHrptTVVGSWE1MyAp37BDwZdU6zWivpZdJgDWnmEou8,36007
132
132
  src/sources/hive/__init__.py,sha256=AYWKwROWOgaMDdRTakg5g3vqQqjECKXiozADPr4MeSM,57
133
- src/sources/hive/source.py,sha256=uv2_-6fBnHU8wEc-a04QRY4N_yzknxVBlC5WiqAodiY,14940
133
+ src/sources/hive/source.py,sha256=Ls9uAugR91t-U13hjtv0uj7xJE0VY-n-BhxYqjmpW-g,15017
134
134
  src/sources/hugging_face/__init__.py,sha256=m0E030elrR2LcpIKEv6OBGDAh4BR5HjLrRVpVNB6WRE,71
135
135
  src/sources/hugging_face/source.py,sha256=F81pc5cEE56s2xEStfqYrY7tRbPcpiwzcSiqB3Shggg,24802
136
136
  src/sources/iceberg/__init__.py,sha256=Wz3kPUaNMSgMzvP9jFxSIbhijXLE1is8G82Pzxtarb4,63
@@ -147,13 +147,13 @@ src/sources/meilisearch/source.py,sha256=O2FYVnPYginrDlAxB10Ua5IAjWeOIjjuH93wL0Z
147
147
  src/sources/microsoft_365/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
148
148
  src/sources/microsoft_365/source.py,sha256=RUUK_EJMzHWs-Y_pebeehXA6g58YpFm-yS9hAat7ntg,39907
149
149
  src/sources/mongodb/__init__.py,sha256=IcSTuVMB3JOXAjYpwFcFvRuc2a7m6xgZ1VG5AfM4eJU,63
150
- src/sources/mongodb/source.py,sha256=klieVNqzxn4-D_PPgUsAtIc5A6Vas8l4K03X9m2x8aA,25992
150
+ src/sources/mongodb/source.py,sha256=BBHPKqNb29SHvjiFPpaFPaFojx_oc5H5vsCZD6R2cQk,26068
151
151
  src/sources/mssql/__init__.py,sha256=Y2WuywS2C8lIsiwbikGKQQ9S3Pbp4X5cHyXiDNz8JPI,59
152
152
  src/sources/mssql/source.py,sha256=NpsKGZSjoENEvekNZ5BW2WJz6d2DAR24Qp3RsPN_wVQ,26572
153
153
  src/sources/mysql/__init__.py,sha256=V0hGtrFJLjGpc3iXzt2k8fH0WzAnyuuDG_2FogcwJBE,59
154
- src/sources/mysql/source.py,sha256=vHeV4fJ6W78xS5y6hCphbgG4QPAvVmSP1167qOxYDbI,16335
154
+ src/sources/mysql/source.py,sha256=r-VtJeGHjGCTimK3ZcCC9iXHETeaYm_F7XmyhebKHIU,16351
155
155
  src/sources/neo4j/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
156
- src/sources/neo4j/source.py,sha256=UYTVnmvrhterM21498V23qmOVMNdasgTyzWpmDF5Ux4,20208
156
+ src/sources/neo4j/source.py,sha256=Fp2qXNJcMrG5TobzF1qNeLRK0ExoDH056Pd-nZ4LMzA,20334
157
157
  src/sources/notion/__init__.py,sha256=FIOPK4itaRGa449jBpvRQd_qlJPPRsjIhSoUBUnMem0,61
158
158
  src/sources/notion/client.py,sha256=Wu-JSXI9KnDgfRUzzP8Vm-_TbhY2FDHdEID_Z-EcMd8,8262
159
159
  src/sources/notion/source.py,sha256=vaqWGVDtdlBAbuhI2vBf3zRQIG56-9C7_yBuflM1DS8,38218
@@ -205,14 +205,14 @@ src/utils/resources.py,sha256=2ejFjz9r_Kv_d-sUGD0oj54U-ZIUSxDAu7aD4YEv4FQ,2630
205
205
  src/utils/sampling_cursor.py,sha256=J1k91Js9ks3mp3JMOb0Jcy5XOkWQlvLDBlxzCXzrNfg,1864
206
206
  src/utils/sampling_frontier.py,sha256=4e8Ugy5NiPaEr2YGpQ5-i_Dy3m8J_aOSl81HlFTX_PU,25232
207
207
  src/utils/source_files.py,sha256=OKZSW64cur0HTuWPRkV1sJg_Z2LFGWzUsC853gGlFBY,5033
208
- src/utils/sql_lineage.py,sha256=r9ySlFs-2RpGDHcfAplBu-JHcYRyb6v9xj60ahvtVf0,21465
208
+ src/utils/sql_lineage.py,sha256=ssthZV7W9x-mjQuOybRpX64s7NFnwyubR-0mizRKm0s,21980
209
209
  src/utils/torch_runtime.py,sha256=79NYfvJ7fjHGgeLXX8rK63qJFuO2Gao1jjRAQ4nicrk,2527
210
210
  src/utils/transcription.py,sha256=WOitCBNTZ7rrGq41ZGH99XmAIyqxvqmnJHxbK3RP7Rs,16943
211
211
  src/utils/urn.py,sha256=3ceITLRB9kkIcFghxA_wQNrcuKDqUuHgmhrxZH2wal0,12161
212
212
  src/utils/uv_sync.py,sha256=VZ0TZ7t3_pSjxOb20BxUs2XLVjigXnVqa5y69XWlnE8,9307
213
213
  src/utils/validation.py,sha256=5NHsgyZwyEONRu9_ADxUAQNPvbMnpMhKL-hpgFEVMm8,3837
214
214
  src/utils/video_processing.py,sha256=B6Br9ybFEa_ioglJGo0P9Fmp1e2B95C24joAktwrjww,11293
215
- classifyre_cli-0.7.0.dist-info/METADATA,sha256=EfXnWAQo_q7BkqzxLYz5kHDlqNBV2IHhpX6450fh0rw,5850
216
- classifyre_cli-0.7.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
217
- classifyre_cli-0.7.0.dist-info/entry_points.txt,sha256=edxllyMiIFfajgCZ3dTRACXYGC34ZX1AYTezMSR5E_8,45
218
- classifyre_cli-0.7.0.dist-info/RECORD,,
215
+ classifyre_cli-0.7.2.dist-info/METADATA,sha256=qXrUZKt1K1yD5-Ki51dI9EMooHoOJ9CgWw88LQyNlwo,5850
216
+ classifyre_cli-0.7.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
217
+ classifyre_cli-0.7.2.dist-info/entry_points.txt,sha256=edxllyMiIFfajgCZ3dTRACXYGC34ZX1AYTezMSR5E_8,45
218
+ classifyre_cli-0.7.2.dist-info/RECORD,,
src/graph/edges.py CHANGED
@@ -131,6 +131,7 @@ class FieldTransform(StrEnum):
131
131
  IDENTITY = "IDENTITY" # copied through unchanged
132
132
  TRANSFORMED = "TRANSFORMED" # an expression combined the upstreams
133
133
  AGGREGATED = "AGGREGATED" # SUM/COUNT/... over a group
134
+ MAPPED = "MAPPED" # the platform says it feeds this column, not how
134
135
  INDIRECT = "INDIRECT" # influenced the rows, not the values
135
136
 
136
137
 
src/main.py CHANGED
@@ -971,6 +971,14 @@ async def run_command_async(args: argparse.Namespace, recipe: dict[str, Any]) ->
971
971
  source.partial_coverage_reason or "no reason given",
972
972
  )
973
973
 
974
+ if getattr(source, "listing_complete", False) and hasattr(
975
+ sink, "set_listing_complete"
976
+ ):
977
+ sink.set_listing_complete()
978
+
979
+ if hasattr(sink, "set_sweep_complete"):
980
+ sink.set_sweep_complete(getattr(source, "sweep_complete", None))
981
+
974
982
  # Relationships go *before* finish(): finish() finalizes the
975
983
  # run and marks it COMPLETED, and edges that land after that
976
984
  # belong to a run that already claimed to be done.
src/outputs/rest.py CHANGED
@@ -203,6 +203,10 @@ class FinalizeIngestRunRequest(BaseModel):
203
203
  # "This run covered a slice of the source." Suppresses retirement: absence
204
204
  # from a cohort run is not evidence that an asset is gone.
205
205
  partial_coverage: bool | None = Field(None, serialization_alias="partialCoverage")
206
+ discovery_complete: bool | None = Field(None, serialization_alias="discoveryComplete")
207
+ # Whether every unit of an AUTOMATIC sweep that wraps has been covered once.
208
+ # Only sent by a run with a wrapping unit; see BaseSource.sweep_complete.
209
+ sweep_complete: bool | None = Field(None, serialization_alias="sweepComplete")
206
210
  # Per ctx.cohort(): keys each band visited, and the weights it ran with.
207
211
  cohort_stats: dict[str, Any] | None = Field(None, serialization_alias="cohortStats")
208
212
  # Detectors the circuit breaker disabled mid-run (scan-cache identity
@@ -261,6 +265,8 @@ class RestOutputSink:
261
265
  self._scan_cache_savings: tuple[int, int] | None = None
262
266
  self._partial_coverage: bool = False
263
267
  self._partial_coverage_reason: str = ""
268
+ self._listing_complete: bool = False
269
+ self._sweep_complete: bool | None = None
264
270
  self._detectors_degraded: list[str] = []
265
271
  self._cohort_stats: dict[str, Any] | None = None
266
272
  self.relationship_report = RelationshipReport()
@@ -279,6 +285,14 @@ class RestOutputSink:
279
285
  if reason:
280
286
  self._partial_coverage_reason = str(reason)
281
287
 
288
+ def set_listing_complete(self) -> None:
289
+ """Mark that discovery named every object of the configured scope."""
290
+ self._listing_complete = True
291
+
292
+ def set_sweep_complete(self, complete: bool | None) -> None:
293
+ """Record whether the wrapping sweep has covered all its ground once."""
294
+ self._sweep_complete = complete
295
+
282
296
  def set_detectors_degraded(self, keys: list[str]) -> None:
283
297
  """Record detectors the breaker disabled mid-run, for the next run's sweep."""
284
298
  seen: set[str] = set(self._detectors_degraded)
@@ -415,6 +429,8 @@ class RestOutputSink:
415
429
  relationships_dropped=lineage.dropped,
416
430
  relationship_errors=lineage.errors or None,
417
431
  partial_coverage=True if self._partial_coverage else None,
432
+ discovery_complete=True if self._listing_complete else None,
433
+ sweep_complete=self._sweep_complete,
418
434
  cohort_stats=self._cohort_stats,
419
435
  detectors_degraded=self._detectors_degraded or None,
420
436
  )
src/sources/base.py CHANGED
@@ -89,6 +89,12 @@ class BaseSource(ABC):
89
89
  self._next_sampling_cursor: dict[str, Any] | None = None
90
90
  self._partial_coverage: bool = False
91
91
  self._partial_coverage_reason: str = ""
92
+ self._listing_complete: bool = False
93
+ self._listing_errors: int = 0
94
+ # Ground an AUTOMATIC sweep has to cover, by cursor key, and whether
95
+ # each unit has been covered once (see ``sweep_complete``).
96
+ self._sweep_units: dict[str, bool] = {}
97
+ self._sweep_wraps: bool = False
92
98
  self._sampling_cursor_lock = threading.Lock()
93
99
  self._apply_initial_sampling_override(normalized_recipe)
94
100
  recipe.clear()
@@ -177,6 +183,72 @@ class BaseSource(ABC):
177
183
  """
178
184
  return self._next_sampling_cursor
179
185
 
186
+ # ── Has the sweep gone round once? ───────────────────────────────────
187
+ #
188
+ # A cursor that wraps (an offset that returns to 0 at the end, a walk that
189
+ # starts over) never stops moving, by design: walking again is the only way
190
+ # a store that cannot be ordered by age shows its new rows. So "the cursor
191
+ # moved" cannot tell a sweep that is still reaching new ground from one that
192
+ # is going round for the second time, and a scheduler that reads it that way
193
+ # re-runs such a source every minute for good.
194
+ #
195
+ # Each unit of ground therefore records, once, that it has been covered: a
196
+ # frontier says so itself (``done``); a wrapping unit says so by wrapping
197
+ # (kept in the cursor under ``LAPPED_KEY``, which only ever grows). The run
198
+ # reports ``sweep_complete`` when every unit it touched has been covered and
199
+ # at least one of them wraps -- the only case the cursor cannot speak for.
200
+
201
+ #: Cursor key listing the wrapping units that have been covered once.
202
+ LAPPED_KEY = "_lapped"
203
+
204
+ def _lapped_keys(self) -> set[str]:
205
+ """Wrapping units known to have been covered once, as of this run.
206
+
207
+ Callers hold ``_sampling_cursor_lock`` when they mean to write back.
208
+ """
209
+ keys: set[str] = set()
210
+ for cursor in (self._sampling_cursor, self._next_sampling_cursor):
211
+ raw = cursor.get(self.LAPPED_KEY) if isinstance(cursor, dict) else None
212
+ if isinstance(raw, list):
213
+ keys.update(str(k) for k in raw)
214
+ return keys
215
+
216
+ def note_sweep_frontier(self, key: str, *, done: bool) -> None:
217
+ """Record whether the frontier under ``key`` has covered its ground."""
218
+ self._sweep_units[key] = bool(done)
219
+
220
+ def note_sweep_wrap(self, key: str, *, wrapped: bool) -> None:
221
+ """Record that the wrapping unit ``key`` was read this run.
222
+
223
+ ``wrapped`` says this run reached its end and starts over. That is
224
+ remembered in the cursor from then on, so a later run that is
225
+ mid-walk still counts the unit as covered.
226
+ """
227
+ with self._sampling_cursor_lock:
228
+ self._sweep_wraps = True
229
+ lapped = self._lapped_keys()
230
+ if wrapped and key not in lapped:
231
+ lapped.add(key)
232
+ nxt = (
233
+ self._next_sampling_cursor
234
+ if isinstance(self._next_sampling_cursor, dict)
235
+ else dict(self._sampling_cursor)
236
+ )
237
+ self._next_sampling_cursor = {**nxt, self.LAPPED_KEY: sorted(lapped)}
238
+ self._sweep_units[key] = key in lapped
239
+
240
+ @property
241
+ def sweep_complete(self) -> bool | None:
242
+ """Whether every unit this run read has been covered at least once.
243
+
244
+ None when the run has nothing to say: no unit of it wraps, so the
245
+ cursor itself already tells a finished sweep (it stops moving).
246
+ """
247
+ units = getattr(self, "_sweep_units", None)
248
+ if not getattr(self, "_sweep_wraps", False) or not units:
249
+ return None
250
+ return all(units.values())
251
+
180
252
  def declare_partial_coverage(self, reason: str = "") -> None:
181
253
  """Say that this run looked at only part of the source.
182
254
 
@@ -191,6 +263,34 @@ class BaseSource(ABC):
191
263
  if reason and not self._partial_coverage_reason:
192
264
  self._partial_coverage_reason = str(reason)
193
265
 
266
+ def note_listing_error(self) -> None:
267
+ """Record that part of the catalog could not be listed this run.
268
+
269
+ A listing that skipped a schema, a folder or a label cannot say that
270
+ what it did not list is gone, so it forfeits :meth:`declare_listing_complete`.
271
+ """
272
+ self._listing_errors = getattr(self, "_listing_errors", 0) + 1
273
+
274
+ def declare_listing_complete(self) -> None:
275
+ """Say that discovery named every object of the configured scope.
276
+
277
+ Sampling decides how much of each asset is read, not which assets
278
+ exist. A catalog connector (tables, node labels) lists its whole scope
279
+ on every run whatever the strategy, so an asset missing from that list
280
+ is gone and can be retired -- without this, only strategy=ALL ever
281
+ retired anything and every other source kept deleted tables, moved
282
+ databases and renamed labels as "unchanged" forever.
283
+
284
+ Ignored when any part of the listing failed or the run declared partial
285
+ coverage.
286
+ """
287
+ if getattr(self, "_listing_errors", 0) == 0:
288
+ self._listing_complete = True
289
+
290
+ @property
291
+ def listing_complete(self) -> bool:
292
+ return bool(getattr(self, "_listing_complete", False)) and not self.partial_coverage
293
+
194
294
  @property
195
295
  def partial_coverage(self) -> bool:
196
296
  return bool(getattr(self, "_partial_coverage", False))
@@ -231,16 +331,24 @@ class BaseSource(ABC):
231
331
  saved = self._sampling_cursor.get(key)
232
332
  return saved if isinstance(saved, int) and not isinstance(saved, bool) and saved >= 0 else 0
233
333
 
234
- def record_automatic_offset(self, key: str, *, prev_offset: int, fetched: int) -> None:
334
+ def record_automatic_offset(
335
+ self, key: str, *, prev_offset: int, fetched: int, track: bool = True
336
+ ) -> None:
235
337
  """Advance a keyed offset cursor; wrap to 0 once a page underfills.
236
338
 
237
339
  For a store that can only be paged by position (``skip``/``OFFSET``)
238
340
  and offers nothing to order by, so new rows cannot be told from old
239
341
  ones. Wrapping is how such a source ever sees them.
342
+
343
+ ``track=False`` keeps the unit out of ``sweep_complete``, for a caller
344
+ whose cursor holds state this offset knows nothing about.
240
345
  """
241
346
  size = self.sampling_window_size()
242
- next_offset = 0 if fetched < size else prev_offset + fetched
347
+ wrapped = fetched < size
348
+ next_offset = 0 if wrapped else prev_offset + fetched
243
349
  self._record_cursor_key(key, next_offset)
350
+ if track:
351
+ self.note_sweep_wrap(key, wrapped=wrapped)
244
352
 
245
353
  def automatic_window(
246
354
  self,
@@ -314,7 +422,9 @@ class BaseSource(ABC):
314
422
  )
315
423
  window = items[offset : offset + size]
316
424
  next_offset = offset + len(window)
317
- self._record_cursor_key(key, 0 if next_offset >= len(items) else next_offset)
425
+ wrapped = next_offset >= len(items)
426
+ self._record_cursor_key(key, 0 if wrapped else next_offset)
427
+ self.note_sweep_wrap(key, wrapped=wrapped)
318
428
  return [item for _, item in window]
319
429
 
320
430
  def automatic_ranged_window(
@@ -381,6 +491,7 @@ class BaseSource(ABC):
381
491
  state = frontier.to_state()
382
492
  if state is not None:
383
493
  self._record_cursor_key(key, state)
494
+ self.note_sweep_frontier(key, done=frontier.done or frontier.fresh)
384
495
  return window
385
496
 
386
497
  def automatic_frontier(self, key: str) -> ListFrontier:
@@ -396,6 +507,7 @@ class BaseSource(ABC):
396
507
  state = frontier.to_state()
397
508
  if state is not None:
398
509
  self._record_cursor_key(key, state)
510
+ self.note_sweep_frontier(key, done=frontier.done or frontier.fresh)
399
511
 
400
512
  @staticmethod
401
513
  def _read_bool_env(name: str) -> bool | None:
@@ -622,7 +622,7 @@ class CustomSource(BaseSource):
622
622
  # P15). The offset is merged in, never substituted.
623
623
  self.set_next_sampling_cursor({k: v for k, v in self._sampling_cursor.items() if k != key})
624
624
  self.record_automatic_offset(
625
- key, prev_offset=offset, fetched=int(frame.get("produced") or 0)
625
+ key, prev_offset=offset, fetched=int(frame.get("produced") or 0), track=False
626
626
  )
627
627
 
628
628
  def _handled_offset(self, frame: dict[str, Any]) -> bool:
@@ -7,7 +7,7 @@ from collections.abc import AsyncGenerator, Generator
7
7
  from contextlib import closing
8
8
  from dataclasses import dataclass
9
9
  from datetime import UTC, datetime, timedelta
10
- from typing import Any
10
+ from typing import Any, ClassVar
11
11
  from urllib.parse import urlparse
12
12
 
13
13
  import requests
@@ -19,6 +19,7 @@ from ...graph.edges import (
19
19
  Method,
20
20
  Ref,
21
21
  flow,
22
+ same_as,
22
23
  )
23
24
  from ...models.generated_input import (
24
25
  AzureServicePrincipal,
@@ -68,6 +69,16 @@ class PipelineRef:
68
69
  state: str | None
69
70
 
70
71
 
72
+ @dataclass(frozen=True)
73
+ class _ForeignSystem:
74
+ """The database behind a foreign catalog, as its own connector names it."""
75
+
76
+ platform: str
77
+ authority: str
78
+ database: str | None
79
+ connection: str
80
+
81
+
71
82
  def _quote_identifier(identifier: str) -> str:
72
83
  return f"`{identifier.replace('`', '``')}`"
73
84
 
@@ -109,6 +120,10 @@ class DatabricksSource(BaseTabularSource):
109
120
  self._validate_auth_configuration()
110
121
 
111
122
  self.session = requests.Session()
123
+ # table_key -> upstream table -> ids of the notebooks Unity Catalog saw
124
+ # move data between the two. Filled by _lineage_refs_for_table.
125
+ self._lineage_notebook_ids: dict[tuple[str, ...], dict[tuple[str, str, str], set[str]]] = {}
126
+ self._foreign_systems: dict[str, _ForeignSystem | None] = {}
112
127
  self._access_token: str | None = None
113
128
  self._access_token_expiry: datetime | None = None
114
129
 
@@ -409,9 +424,13 @@ class DatabricksSource(BaseTabularSource):
409
424
  without_scheme = raw.split("://", 1)[-1]
410
425
  return without_scheme.strip("/").split("/", 1)[0]
411
426
 
412
- def _available_columns(self, table_ref: TableRef) -> list[str]:
427
+ def _available_column_types(self, table_ref: TableRef) -> dict[str, str]:
428
+ # ``system.information_schema`` sees every catalog from one connection;
429
+ # the default query reads only the connection's current catalog and a
430
+ # parameter style Databricks does not take, so types used to come back
431
+ # empty for every column.
413
432
  query = (
414
- "SELECT column_name "
433
+ "SELECT column_name, COALESCE(full_data_type, data_type) "
415
434
  "FROM system.information_schema.columns "
416
435
  f"WHERE table_catalog = {_quote_literal(table_ref.database)} "
417
436
  f"AND table_schema = {_quote_literal(table_ref.schema or '')} "
@@ -421,17 +440,17 @@ class DatabricksSource(BaseTabularSource):
421
440
  conn = self._get_cached_connection(table_ref.database)
422
441
  with conn.cursor() as cursor:
423
442
  cursor.execute(query)
424
- columns: list[str] = []
443
+ columns: dict[str, str] = {}
425
444
  for row in cursor.fetchall():
426
- candidate: Any | None = None
427
- try:
428
- candidate = row[0] # type: ignore[index]
429
- except Exception:
430
- candidate = None
431
- if isinstance(candidate, str):
432
- columns.append(candidate)
445
+ name = row[0] if len(row) > 0 else None
446
+ if isinstance(name, str):
447
+ data_type = row[1] if len(row) > 1 else None
448
+ columns[name] = str(data_type) if data_type is not None else ""
433
449
  return columns
434
450
 
451
+ def _available_columns(self, table_ref: TableRef) -> list[str]:
452
+ return list(self._available_column_types(table_ref))
453
+
435
454
  # ── Databricks-specific cell serialization ───────────────────────────
436
455
 
437
456
  def _serialize_cell(self, value: Any) -> str:
@@ -598,6 +617,7 @@ class DatabricksSource(BaseTabularSource):
598
617
  try:
599
618
  schemas = self._list_schemas_for_catalog(database)
600
619
  except Exception as exc:
620
+ self.note_listing_error()
601
621
  logger.warning("Skipping catalog %s due to schema listing error: %s", database, exc)
602
622
  return tables
603
623
  for schema in schemas:
@@ -606,6 +626,7 @@ class DatabricksSource(BaseTabularSource):
606
626
  try:
607
627
  tables.extend(self._list_tables_for_schema(database, schema))
608
628
  except Exception as exc:
629
+ self.note_listing_error()
609
630
  logger.warning(
610
631
  "Skipping schema %s.%s due to table listing error: %s",
611
632
  database,
@@ -653,14 +674,30 @@ class DatabricksSource(BaseTabularSource):
653
674
  },
654
675
  )
655
676
  refs: set[tuple[str, str, str]] = set()
677
+ notebooks: dict[tuple[str, str, str], set[str]] = {}
656
678
  for key in ("upstreams", "upstream_tables"):
657
679
  entries = response.get(key)
658
680
  if isinstance(entries, list):
659
681
  for entry in entries:
660
682
  if isinstance(entry, dict):
683
+ if (entry.get("tableInfo") or {}).get("has_permission") is False:
684
+ # Unity Catalog knows the upstream and will not name
685
+ # it to this identity, so the table looks sourceless.
686
+ logger.warning(
687
+ "%s has an upstream this identity may not see; "
688
+ "grant it BROWSE on that catalog",
689
+ table_ref.display_name,
690
+ )
691
+ continue
661
692
  parsed = self._lineage_table_ref_from_payload(entry)
662
693
  if parsed:
663
694
  refs.add(parsed)
695
+ for info in entry.get("notebookInfos") or []:
696
+ if isinstance(info, dict) and info.get("notebook_id") is not None:
697
+ notebooks.setdefault(parsed, set()).add(
698
+ str(info["notebook_id"])
699
+ )
700
+ self._lineage_notebook_ids[table_ref.table_key] = notebooks
664
701
  return refs
665
702
 
666
703
  def _emit_table_lineage(
@@ -668,6 +705,7 @@ class DatabricksSource(BaseTabularSource):
668
705
  table_ref: TableRef,
669
706
  upstream_refs: set[tuple[str, str, str]],
670
707
  table_hash_by_key: dict[tuple[str, ...], str],
708
+ notebook_hash_by_id: dict[str, str] | None = None,
671
709
  ) -> None:
672
710
  """Turn Unity Catalog's lineage into typed edges.
673
711
 
@@ -675,6 +713,9 @@ class DatabricksSource(BaseTabularSource):
675
713
  question directly, so nothing here is parsed out of SQL — these edges
676
714
  are as trustworthy as lineage gets, and are marked SYSTEM_CATALOG.
677
715
 
716
+ When Unity Catalog saw a notebook run the statement, the edge carries it
717
+ as ``via`` -- if that notebook is one of this scan's assets.
718
+
678
719
  The upstreams that are *not* part of this scan are the important half.
679
720
  They used to be dropped on the floor (``if target in table_hash_by_key``),
680
721
  which meant a table fed by a catalog outside the configured scope looked
@@ -686,23 +727,52 @@ class DatabricksSource(BaseTabularSource):
686
727
  return
687
728
 
688
729
  column_mappings = self._column_mappings_for_table(table_ref)
730
+ notebook_ids = self._lineage_notebook_ids.get(table_ref.table_key, {})
689
731
  for upstream in sorted(upstream_refs):
690
732
  upstream_hash = table_hash_by_key.get(upstream)
691
- upstream_ref = (
692
- Ref.asset(upstream_hash) if upstream_hash else self._urn_ref_for_key(upstream)
693
- )
733
+ foreign_urn = self._foreign_urn(upstream)
734
+ upstream_ref: Ref | None
735
+ if foreign_urn:
736
+ upstream_ref = Ref.urn(foreign_urn)
737
+ elif upstream_hash:
738
+ upstream_ref = Ref.asset(upstream_hash)
739
+ else:
740
+ upstream_ref = self._urn_ref_for_key(upstream)
694
741
  if upstream_ref is None:
695
742
  continue
743
+ via_hash = next(
744
+ (
745
+ notebook_hash_by_id[notebook_id]
746
+ for notebook_id in sorted(notebook_ids.get(upstream, ()))
747
+ if notebook_id in (notebook_hash_by_id or {})
748
+ ),
749
+ None,
750
+ )
696
751
  self.add_edge(
697
752
  flow(
698
753
  upstream=upstream_ref,
699
754
  downstream=Ref.asset(downstream_hash),
700
755
  type=FlowType.TRANSFORM,
701
756
  fields=column_mappings.get(upstream, []),
757
+ via=Ref.asset(via_hash) if via_hash else None,
702
758
  method=Method.SYSTEM_CATALOG,
759
+ evidence=self._foreign_evidence(upstream) if foreign_urn else None,
703
760
  )
704
761
  )
705
762
 
763
+ def _emit_foreign_identity(self, table_ref: TableRef, asset_hash: str) -> None:
764
+ """A scanned table of a foreign catalog is a mirror of the real table."""
765
+ foreign_urn = self._foreign_urn(table_ref.table_key)
766
+ if not foreign_urn:
767
+ return
768
+ self.add_edge(
769
+ same_as(
770
+ Ref.asset(asset_hash),
771
+ Ref.urn(foreign_urn),
772
+ evidence=self._foreign_evidence(table_ref.table_key),
773
+ )
774
+ )
775
+
706
776
  def _column_mappings_for_table(
707
777
  self, table_ref: TableRef
708
778
  ) -> dict[tuple[str, str, str], list[FieldMapping]]:
@@ -744,14 +814,12 @@ class DatabricksSource(BaseTabularSource):
744
814
 
745
815
  return {
746
816
  upstream: [
817
+ # Unity Catalog names the columns and nothing between them, so a
818
+ # matching name proves nothing: `lower(email) AS email` matches.
747
819
  FieldMapping(
748
820
  downstream=downstream,
749
821
  upstreams=sorted(sources),
750
- type=(
751
- FieldTransform.IDENTITY
752
- if sources == {downstream}
753
- else FieldTransform.TRANSFORMED
754
- ),
822
+ type=FieldTransform.MAPPED,
755
823
  )
756
824
  for downstream, sources in sorted(columns.items())
757
825
  ]
@@ -767,6 +835,86 @@ class DatabricksSource(BaseTabularSource):
767
835
  except UrnError:
768
836
  return None
769
837
 
838
+ # ── Foreign catalogs (Lakehouse Federation) ──────────────────────────
839
+ #
840
+ # A foreign catalog is Databricks' name for tables that live in another
841
+ # database. Unity Catalog records lineage through it like any other catalog,
842
+ # so without this the upstream is `databricks://.../app_db/core/customers`,
843
+ # a name no PostgreSQL scan will ever write, and the path stops at the
844
+ # boundary it was most worth crossing.
845
+
846
+ #: UC connection type -> (URN platform, catalog option naming the database).
847
+ #: None where the schema level already is the database, as in MySQL.
848
+ _FOREIGN_PLATFORMS: ClassVar[dict[str, tuple[str, str | None]]] = {
849
+ "POSTGRESQL": ("postgres", "database"),
850
+ "MYSQL": ("mysql", None),
851
+ "SQLSERVER": ("mssql", "database"),
852
+ "ORACLE": ("oracle", "service_name"),
853
+ "SNOWFLAKE": ("snowflake", "database"),
854
+ }
855
+
856
+ def _foreign_system(self, catalog: str) -> _ForeignSystem | None:
857
+ if catalog in self._foreign_systems:
858
+ return self._foreign_systems[catalog]
859
+ system: _ForeignSystem | None = None
860
+ try:
861
+ system = self._read_foreign_system(catalog)
862
+ except Exception as exc:
863
+ # USE CONNECTION is a separate grant. The edge keeps its Databricks
864
+ # name, which is true if not useful, rather than the scan failing.
865
+ logger.warning("Could not read the connection of catalog %s: %s", catalog, exc)
866
+ self._foreign_systems[catalog] = system
867
+ return system
868
+
869
+ def _read_foreign_system(self, catalog: str) -> _ForeignSystem | None:
870
+ info = self._request_json("get", f"/api/2.1/unity-catalog/catalogs/{catalog}")
871
+ connection_name = info.get("connection_name")
872
+ if info.get("catalog_type") != "FOREIGN_CATALOG" or not connection_name:
873
+ return None
874
+ connection = self._request_json(
875
+ "get", f"/api/2.1/unity-catalog/connections/{connection_name}"
876
+ )
877
+ mapping = self._FOREIGN_PLATFORMS.get(str(connection.get("connection_type")))
878
+ if mapping is None:
879
+ return None
880
+ platform, database_option = mapping
881
+
882
+ options = connection.get("options") or {}
883
+ host = str(options.get("host") or "").strip()
884
+ if not host:
885
+ # A URN scoped to the wrong host would stitch two unrelated systems.
886
+ return None
887
+ if platform == "snowflake":
888
+ authority = host.removesuffix(".snowflakecomputing.com")
889
+ else:
890
+ port = options.get("port")
891
+ authority = f"{host}:{port}" if port else host
892
+
893
+ database = None
894
+ if database_option:
895
+ database = str((info.get("options") or {}).get(database_option) or "").strip()
896
+ if not database:
897
+ return None
898
+ return _ForeignSystem(platform, authority, database, str(connection_name))
899
+
900
+ def _foreign_urn(self, table_key: tuple[str, ...]) -> str | None:
901
+ """The URN the underlying system's own connector writes for this table."""
902
+ system = self._foreign_system(table_key[0])
903
+ if system is None:
904
+ return None
905
+ name = (system.database, *table_key[1:]) if system.database else table_key[1:]
906
+ try:
907
+ return str(Urn.of(system.platform, system.authority, *name))
908
+ except UrnError:
909
+ return None
910
+
911
+ def _foreign_evidence(self, table_key: tuple[str, ...]) -> dict[str, Any]:
912
+ system = self._foreign_system(table_key[0])
913
+ return {
914
+ "foreign_catalog": table_key[0],
915
+ "connection": system.connection if system else None,
916
+ }
917
+
770
918
  # ── Notebooks ────────────────────────────────────────────────────────
771
919
 
772
920
  def _iter_notebooks(self) -> Generator[NotebookRef, None, None]:
@@ -786,6 +934,7 @@ class DatabricksSource(BaseTabularSource):
786
934
  "get", "/api/2.0/workspace/list", params={"path": path}
787
935
  )
788
936
  except Exception as exc:
937
+ self.note_listing_error()
789
938
  logger.warning("Skipping workspace path %s due to listing error: %s", path, exc)
790
939
  continue
791
940
  objects = payload.get("objects")
@@ -865,6 +1014,7 @@ class DatabricksSource(BaseTabularSource):
865
1014
  try:
866
1015
  payload = self._request_json("get", "/api/2.0/pipelines", params=params)
867
1016
  except Exception as exc:
1017
+ self.note_listing_error()
868
1018
  logger.warning("Could not list Databricks pipelines: %s", exc)
869
1019
  break
870
1020
  values: list[dict[str, Any]] = []
@@ -935,6 +1085,15 @@ class DatabricksSource(BaseTabularSource):
935
1085
  t.table_key: self.generate_hash_id(t.raw_id) for t in tables
936
1086
  }
937
1087
 
1088
+ # Listed before the tables so a lineage edge can name the notebook that
1089
+ # produced it; the notebook assets are still emitted after the tables.
1090
+ notebooks = list(self._iter_notebooks())
1091
+ notebook_hash_by_id = {
1092
+ nb.object_id: self.generate_hash_id(f"notebook_#_{nb.path}")
1093
+ for nb in notebooks
1094
+ if nb.object_id
1095
+ }
1096
+
938
1097
  include_lineage = self._extraction_options().include_table_lineage
939
1098
  if include_lineage and tables:
940
1099
  logger.info("Fetching table lineage for %d table(s)...", len(tables))
@@ -957,7 +1116,9 @@ class DatabricksSource(BaseTabularSource):
957
1116
  for target in sorted(upstream_refs)
958
1117
  if target in table_hash_by_key
959
1118
  ]
960
- self._emit_table_lineage(table_ref, upstream_refs, table_hash_by_key)
1119
+ self._emit_table_lineage(
1120
+ table_ref, upstream_refs, table_hash_by_key, notebook_hash_by_id
1121
+ )
961
1122
  except Exception as exc:
962
1123
  logger.warning(
963
1124
  "Could not resolve Databricks lineage for %s: %s", table_label, exc
@@ -967,6 +1128,8 @@ class DatabricksSource(BaseTabularSource):
967
1128
  self._table_lookup[asset.hash] = table_ref
968
1129
  batch.append(asset)
969
1130
  emitted_tables += 1
1131
+ if include_lineage:
1132
+ self._emit_foreign_identity(table_ref, asset.hash)
970
1133
 
971
1134
  if len(batch) >= self.BATCH_SIZE:
972
1135
  yield batch
@@ -974,7 +1137,7 @@ class DatabricksSource(BaseTabularSource):
974
1137
 
975
1138
  # Notebooks
976
1139
  notebook_count = 0
977
- for notebook in self._iter_notebooks():
1140
+ for notebook in notebooks:
978
1141
  if self._aborted:
979
1142
  break
980
1143
  batch.append(self._notebook_to_asset(notebook))
@@ -997,6 +1160,7 @@ class DatabricksSource(BaseTabularSource):
997
1160
  batch = []
998
1161
  if pipeline_count:
999
1162
  logger.info("Discovered %d pipeline(s)", pipeline_count)
1163
+ self._declare_listing_if_whole()
1000
1164
 
1001
1165
  if batch:
1002
1166
  yield batch
@@ -293,7 +293,10 @@ class HiveSource(BaseTabularSource):
293
293
  upstreams = [
294
294
  key
295
295
  for key in upstream_tables_from_sql(
296
- definition, dialect="hive", default_database=view.database
296
+ definition,
297
+ dialect="hive",
298
+ default_database=view.database,
299
+ two_level=True,
297
300
  )
298
301
  if key != view.table_key and key in scoped
299
302
  ]
@@ -507,6 +507,7 @@ class MongoDBSource(BaseSource):
507
507
  if next_state.get(name) is not None:
508
508
  next_state[name] = self._encode_id(next_state[name])
509
509
  self._record_cursor_key(key, next_state)
510
+ self.note_sweep_frontier(key, done=frontier.done or frontier.fresh)
510
511
  return documents
511
512
 
512
513
  def _sample_collection_documents(self, collection_ref: CollectionRef) -> list[dict[str, Any]]:
@@ -322,7 +322,7 @@ class MySQLSource(BaseTabularSource):
322
322
  upstreams = [
323
323
  key
324
324
  for key in upstream_tables_from_sql(
325
- definition, dialect="mysql", default_database=database
325
+ definition, dialect="mysql", default_database=database, two_level=True
326
326
  )
327
327
  if key != view_key
328
328
  ]
@@ -325,6 +325,9 @@ class Neo4jSource(BaseSource):
325
325
  yield batch
326
326
  batch = []
327
327
 
328
+ if not self._aborted and not self._scope_options().node_limit_per_label:
329
+ self.declare_listing_complete()
330
+
328
331
  if batch:
329
332
  logger.info("Emitting final batch of %d asset(s)", len(batch))
330
333
  yield batch
@@ -319,9 +319,23 @@ class BaseTabularSource(BaseSource):
319
319
  try:
320
320
  tables.extend(self._list_tables_for_database(database))
321
321
  except Exception as exc:
322
+ self.note_listing_error()
322
323
  logger.warning("Skipping database %s due to listing error: %s", database, exc)
323
324
  return tables
324
325
 
326
+ def _listing_is_limited(self) -> bool:
327
+ """Whether a configured cap can have cut the table list short."""
328
+ scope_options = getattr(self, "_scope_options", None)
329
+ scope = scope_options() if callable(scope_options) else None
330
+ return bool(
331
+ getattr(scope, "table_limit", None) or getattr(scope, "table_limit_per_schema", None)
332
+ )
333
+
334
+ def _declare_listing_if_whole(self) -> None:
335
+ """Offer this run's table list as the whole scope, when it plausibly is."""
336
+ if not self._aborted and not self._listing_is_limited():
337
+ self.declare_listing_complete()
338
+
325
339
  # ── Primary keys ─────────────────────────────────────────────────────
326
340
 
327
341
  def _get_primary_key_columns(self, table_ref: TableRef) -> list[str]:
@@ -689,6 +703,18 @@ class BaseTabularSource(BaseSource):
689
703
  nxt["tables"] = tables
690
704
  self._next_sampling_cursor = nxt
691
705
  self._automatic_advanced.add(key)
706
+ self._note_table_sweep(key, state)
707
+
708
+ def _note_table_sweep(self, key: str, state: dict[str, Any] | None) -> None:
709
+ """Tell the sweep tracker whether this table has been covered once."""
710
+ if state is None:
711
+ self.note_sweep_frontier(key, done=True)
712
+ elif state.get("mode") == self._WRAP_MODE:
713
+ # Walked and wrapped: covered once the walk has reached the end.
714
+ self._sweep_wraps = True
715
+ self._sweep_units[key] = bool(state.get("lapped"))
716
+ else:
717
+ self.note_sweep_frontier(key, done=bool(state.get("done")) or not state.get("head"))
692
718
 
693
719
  def _fetch_page_key_range(
694
720
  self,
@@ -836,6 +862,7 @@ class BaseTabularSource(BaseSource):
836
862
  )
837
863
  # Exhausted (or a key that cannot be stored, e.g. binary) leaves no
838
864
  # position, so the next run starts the walk again.
865
+ exhausted = len(rows) < rows_per_page
839
866
  if rows and len(rows) >= rows_per_page:
840
867
  last_row = rows[-1]
841
868
  position = [
@@ -846,8 +873,13 @@ class BaseTabularSource(BaseSource):
846
873
  else:
847
874
  offset = int(saved.get("offset") or 0)
848
875
  rows, column_names = self._fetch_one_page(table_ref, base_query, rows_per_page, offset)
876
+ exhausted = len(rows) < rows_per_page
849
877
  if rows and len(rows) >= rows_per_page:
850
878
  next_state["offset"] = offset + len(rows)
879
+ # Sticky: once the walk has reached the end, the table counts as
880
+ # covered however many times it is walked again.
881
+ if exhausted or saved.get("lapped"):
882
+ next_state["lapped"] = True
851
883
 
852
884
  self._record_table_cursor(key, next_state)
853
885
 
@@ -1097,6 +1129,7 @@ class BaseTabularSource(BaseSource):
1097
1129
  table_hash_by_key: dict[tuple[str, ...], str] = {
1098
1130
  t.table_key: self.generate_hash_id(t.raw_id) for t in tables
1099
1131
  }
1132
+ self._declare_listing_if_whole()
1100
1133
  fk_links = self._collect_foreign_key_links(tables)
1101
1134
  self._emit_relationship_edges(tables, table_hash_by_key, fk_links)
1102
1135
 
src/utils/sql_lineage.py CHANGED
@@ -444,6 +444,7 @@ def upstream_tables_from_sql(
444
444
  dialect: str | None = None,
445
445
  default_database: str | None = None,
446
446
  default_schema: str | None = None,
447
+ two_level: bool = False,
447
448
  ) -> list[tuple[str, ...]]:
448
449
  """Table keys a statement reads from, for catalogs with no dependency view.
449
450
 
@@ -453,6 +454,11 @@ def upstream_tables_from_sql(
453
454
  the wrong default schema points at nothing — so callers should prefer the
454
455
  catalog wherever it exists and treat these edges as SQL_PARSED.
455
456
 
457
+ ``two_level`` is for engines whose tables are named ``database.table`` (MySQL,
458
+ Hive). sqlglot files the first part under ``db``, so without it a qualified
459
+ name came back as ``(default_database, database, table)`` -- a key no asset
460
+ has, which turned every such view's edges into unresolved ones.
461
+
456
462
  CTE names are excluded: a ``WITH`` alias is not a table, and emitting it
457
463
  would create lineage to an object that does not exist.
458
464
  """
@@ -481,6 +487,11 @@ def upstream_tables_from_sql(
481
487
  name = table.name
482
488
  if not name or name.lower() in cte_names:
483
489
  continue
490
+ if two_level:
491
+ namespace = table.text("db") or default_database
492
+ if namespace:
493
+ keys.add((namespace, name))
494
+ continue
484
495
  schema = table.text("db") or default_schema
485
496
  database = table.text("catalog") or default_database
486
497
  if database and schema: