Skip to content

feat(target): add file:// local filesystem destination - #166

Merged
amotl merged 1 commit into
panodata:mainfrom
hampsterx:feat/file-destination
Jul 5, 2026
Merged

feat(target): add file:// local filesystem destination#166
amotl merged 1 commit into
panodata:mainfrom
hampsterx:feat/file-destination

Conversation

@hampsterx

Copy link
Copy Markdown
Contributor

Summary

  • Add a file:// destination that writes local CSV, JSONL and Parquet files, the write-side counterpart to the file:// source, fixing ValueError: Unsupported destination scheme: file #143 (Unsupported destination scheme: file).
  • Output format comes from the destination file extension or an explicit #format hint, with the same path grammar (relative, absolute, Windows drive and UNC) as the source side.
  • Written files drop dlt's internal _dlt_* columns so they round-trip cleanly; parent directories are created, an existing file is overwritten, and globs are rejected (globbing is read-only).
  • Generalizes the existing CSV-only local-write pattern to three formats and registers the file scheme in the destinations registry.

Behaviour

--dest-uri file://<path>[#format] with --dest-table <dataset>.<table> (the table only names dlt's intermediate layout; the output file is the URI path). The mechanism mirrors the existing csv:// destination: load into a temp dir via dlt's filesystem destination, then reassemble a single clean file in post_load().

omniload ingest \
    --source-uri 'postgres://user:password@host:5432/db' \
    --source-table 'public.users' \
    --dest-uri 'file://export/users.parquet' \
    --dest-table 'public.users'

Relates to #106 (file/table-format handling), and pairs with #135 (the file:// source).

Known trade-off

post_load() buffers the whole load before writing. dlt omits null keys per row, so correct CSV/Parquet output needs the column union across all rows (Parquet also needs the full table), and streaming that correctly would reintroduce the per-row re-header logic the codebase is moving away from. Buffered is the simple, correct first cut; streaming is a follow-up if large local exports become a use case. Happy to change the approach if you'd prefer.

Test plan

  • 36 unit tests in the Docker-free lane: path/format resolution, factory dispatch, glob/empty-path/two-part-table guards, and file://file:// round-trips for csv/jsonl/parquet including empty source, #format hint, unsupported-format error, nested-directory creation, and a heterogeneous-row case (a column missing from the first row must survive).
  • Full unit lane green (187 passed).
  • poe lint clean (ruff, validate-pyproject, ty); poe docs-html clean (-W).

@read-the-docs-community

read-the-docs-community Bot commented Jul 5, 2026

Copy link
Copy Markdown

Documentation build overview

📚 omniload | 🛠️ Build #33444722 | 📁 Comparing 885ce70 against latest (3297f1e)

  🔍 Preview build  

3 files changed
± changelog.html
± supported-sources/file.html
± supported-sources/index.html

@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 52.43%. Comparing base (3297f1e) to head (885ce70).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #166      +/-   ##
==========================================
+ Coverage   51.89%   52.43%   +0.54%     
==========================================
  Files         196      197       +1     
  Lines        9139     9243     +104     
==========================================
+ Hits         4743     4847     +104     
  Misses       4396     4396              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hampsterx

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Summary by CodeRabbit

  • New Features

    • Local files can now be used as an output destination, including CSV, JSONL, and Parquet.
    • Destination format is chosen from the file extension or an explicit format hint.
    • Output files are written cleanly without internal bookkeeping columns, and parent folders are created automatically.
  • Bug Fixes

    • Improved handling of local destination paths, including validation for missing paths, unsupported formats, and invalid glob patterns.
    • Added support for path resolution across common local path styles.

Walkthrough

Adds a write-side local filesystem destination for file:// URIs supporting CSV/JSONL/Parquet output, registers it in the destination registry, implements URI parsing/validation and format-specific writers, adds documentation for the new destination, and adds unit and end-to-end tests.

Changes

file:// Destination Support

Layer / File(s) Summary
URI resolution and validation
omniload/target/filesystem/local.py
Parses path#format hints, resolves file:// destination URIs to an absolute output path and validated format, rejecting empty paths and glob patterns.
Row stripping and writers
omniload/target/filesystem/local.py
Strips _dlt_* bookkeeping columns and implements CSV/JSONL/Parquet writer functions with a dispatch registry.
LocalFilesystemDestination class and registry wiring
omniload/target/filesystem/local.py, omniload/core/registry.py
Implements dlt_dest, dlt_run_params, and post_load to configure the dlt bucket, validate table identifiers, aggregate rows from temp files, write the final output, and clean up; registers "file" in the destinations registry.
Unit and end-to-end tests
tests/main/filesystem/test_local_dest.py
Adds unit tests for dispatch, URI resolution, and validation errors, plus end-to-end tests for round-trip writes, #format hints, failures, schema drift, directory creation, and empty sources.
Documentation
docs/changelog.md, docs/supported-sources/file.md, docs/supported-sources/index.md
Documents the new file:// destination, updates the source/destination comparison, and marks local files as a supported destination in the matrix.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Ingest
  participant LocalFilesystemDestination
  participant TempDir
  participant OutputFile

  Ingest->>LocalFilesystemDestination: dlt_dest(file:// uri)
  LocalFilesystemDestination->>TempDir: create temp bucket
  Ingest->>TempDir: load intermediate rows
  Ingest->>LocalFilesystemDestination: post_load()
  LocalFilesystemDestination->>TempDir: read intermediate data files
  LocalFilesystemDestination->>LocalFilesystemDestination: strip _dlt_ columns
  LocalFilesystemDestination->>OutputFile: write aggregated rows (csv/jsonl/parquet)
  LocalFilesystemDestination->>TempDir: remove temp dir (finally)
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the new file:// local filesystem destination.
Description check ✅ Passed The description is directly related to the changes and accurately summarizes the new destination behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
omniload/target/filesystem/local.py (1)

177-231: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Temp directory cleanup needs a failure path tempfile.mkdtemp() is only reclaimed in post_load(), so any exception before that hook runs (including dlt_run_params() validation or a failed pipeline.run()) leaves the bucket behind. Clean it up from dlt_dest() or a surrounding try/finally so failed runs don’t accumulate orphaned temp dirs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@omniload/target/filesystem/local.py` around lines 177 - 231, The temporary
directory created in dlt_dest() is only removed in post_load(), so failures
before that hook can orphan it. Add a cleanup path around the dlt pipeline
setup/run flow, or ensure dlt_dest() registers a try/finally-style cleanup, so
temp_path is deleted even if dlt_run_params() validation or pipeline.run()
fails. Reference the dlt_dest(), dlt_run_params(), and post_load() flow when
wiring the cleanup to cover all failure paths.
🧹 Nitpick comments (2)
omniload/target/filesystem/local.py (1)

106-160: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated "ordered union of keys" logic.

The first-seen-order key-union loop is duplicated verbatim in _write_csv (lines 112-118) and _write_parquet (lines 144-150). Extracting a shared helper avoids the two copies silently diverging later.

♻️ Proposed refactor
+def _ordered_union_keys(rows: list[dict]) -> list[str]:
+    """Union of keys across rows, in first-seen order."""
+    fieldnames: list[str] = []
+    seen: set[str] = set()
+    for row in rows:
+        for key in row:
+            if key not in seen:
+                seen.add(key)
+                fieldnames.append(key)
+    return fieldnames
+
+
 def _write_csv(path: str, rows: list[dict]) -> None:
     import csv
 
-    # Union of keys in first-seen order: dlt omits null keys per row, so a later row can
-    # carry a column the first row lacked. First-seen order preserves the source column
-    # order (rather than sorting), which is what an export is expected to look like.
-    fieldnames: list[str] = []
-    seen: set[str] = set()
-    for row in rows:
-        for key in row:
-            if key not in seen:
-                seen.add(key)
-                fieldnames.append(key)
+    fieldnames = _ordered_union_keys(rows)
 
     with open(path, "w", newline="") as handle:
         writer = csv.DictWriter(handle, fieldnames=fieldnames, restval="")
         writer.writeheader()
         writer.writerows(rows)

Apply the equivalent change to _write_parquet.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@omniload/target/filesystem/local.py` around lines 106 - 160, The
first-seen-order key-union logic is duplicated in _write_csv and _write_parquet,
so extract it into a shared helper and use that helper from both writers. Keep
the helper responsible for collecting fieldnames from the row dicts in
first-seen order, then have _write_csv use it for DictWriter fieldnames and
_write_parquet use it for building the columns mapping.
tests/main/filesystem/test_local_dest.py (1)

98-213: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing coverage: overwrite of an existing destination file.

The PR explicitly claims the destination "overwrites existing files," but no test writes to a file:// destination that already contains a pre-existing file (e.g., with stale/differently-shaped content) and asserts it is cleanly replaced. Consider adding a case alongside the other e2e tests.

As per path instructions, "Prefer flagging missing edge-case coverage over style nits."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/main/filesystem/test_local_dest.py` around lines 98 - 213, Add an
end-to-end test in test_local_dest.py that covers writing to a file://
destination path that already exists, using the same helpers like
_write_source_files, invoke_ingest_command, and _read_back. Create a
pre-existing out file with stale or differently shaped content, run the ingest
through the existing file-to-file flow, and assert the command succeeds and the
destination is fully replaced with the expected rows for one of the existing
formats. Keep it alongside the other destination tests and name it to reflect
overwrite behavior.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@omniload/target/filesystem/local.py`:
- Around line 106-160: The CSV and Parquet writers only infer columns from the
row data, so empty exports lose the source schema. Update _write_csv and
_write_parquet in local.py to accept or use upstream column metadata when rows
is empty, and make _WRITERS wire through that schema so header-only exports
still emit the expected columns instead of an empty header/table.

In `@tests/main/filesystem/test_local_dest.py`:
- Around line 84-87: The pytest.raises assertion in
test_dlt_run_params_requires_two_part_table is using a regex pattern where the
dot in "<schema>.<table>" is treated as a wildcard, so tighten the match by
escaping the literal dot in the match= string. Update the assertion in this test
to use a regex that exactly matches the intended "<schema>.<table>" message
while keeping the LocalFilesystemDestination.dlt_run_params coverage unchanged.

---

Outside diff comments:
In `@omniload/target/filesystem/local.py`:
- Around line 177-231: The temporary directory created in dlt_dest() is only
removed in post_load(), so failures before that hook can orphan it. Add a
cleanup path around the dlt pipeline setup/run flow, or ensure dlt_dest()
registers a try/finally-style cleanup, so temp_path is deleted even if
dlt_run_params() validation or pipeline.run() fails. Reference the dlt_dest(),
dlt_run_params(), and post_load() flow when wiring the cleanup to cover all
failure paths.

---

Nitpick comments:
In `@omniload/target/filesystem/local.py`:
- Around line 106-160: The first-seen-order key-union logic is duplicated in
_write_csv and _write_parquet, so extract it into a shared helper and use that
helper from both writers. Keep the helper responsible for collecting fieldnames
from the row dicts in first-seen order, then have _write_csv use it for
DictWriter fieldnames and _write_parquet use it for building the columns
mapping.

In `@tests/main/filesystem/test_local_dest.py`:
- Around line 98-213: Add an end-to-end test in test_local_dest.py that covers
writing to a file:// destination path that already exists, using the same
helpers like _write_source_files, invoke_ingest_command, and _read_back. Create
a pre-existing out file with stale or differently shaped content, run the ingest
through the existing file-to-file flow, and assert the command succeeds and the
destination is fully replaced with the expected rows for one of the existing
formats. Keep it alongside the other destination tests and name it to reflect
overwrite behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ae680a56-90d8-41fa-bf48-ec6300e37042

📥 Commits

Reviewing files that changed from the base of the PR and between 3297f1e and af63dcc.

📒 Files selected for processing (6)
  • docs/changelog.md
  • docs/supported-sources/file.md
  • docs/supported-sources/index.md
  • omniload/core/registry.py
  • omniload/target/filesystem/local.py
  • tests/main/filesystem/test_local_dest.py

Comment thread omniload/target/filesystem/local.py
Comment thread tests/main/filesystem/test_local_dest.py
- Add a file:// destination that writes local CSV, JSONL and Parquet
  files, the write-side counterpart to the file:// source, fixing panodata#143
  (Unsupported destination scheme: file).
- Output format comes from the destination file extension or an explicit
  #format hint, with the same path grammar (relative, absolute, Windows
  drive and UNC) as the source side.
- Output drops dlt's internal _dlt_* columns so files round-trip cleanly;
  parent directories are created, and globs are rejected (read-only).
- Generalize the existing CSV-only local-write pattern to three formats,
  and register the file scheme in the destinations registry.
@hampsterx
hampsterx force-pushed the feat/file-destination branch from af63dcc to 885ce70 Compare July 5, 2026 07:08

@amotl amotl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's another excellent patch from your pen, right on the spot. Thank you very much. 💯

@amotl
amotl merged commit eefd26c into panodata:main Jul 5, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants