iceberg-ops 0.1.0__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 (87) hide show
  1. iceberg_ops-0.1.0/.gitignore +70 -0
  2. iceberg_ops-0.1.0/CHANGELOG.md +502 -0
  3. iceberg_ops-0.1.0/LICENSE +21 -0
  4. iceberg_ops-0.1.0/PKG-INFO +425 -0
  5. iceberg_ops-0.1.0/README.md +358 -0
  6. iceberg_ops-0.1.0/docs/api/autogen.rst +6 -0
  7. iceberg_ops-0.1.0/docs/api/catalog.rst +6 -0
  8. iceberg_ops-0.1.0/docs/api/cli.rst +9 -0
  9. iceberg_ops-0.1.0/docs/api/data_migration.rst +46 -0
  10. iceberg_ops-0.1.0/docs/api/history.rst +6 -0
  11. iceberg_ops-0.1.0/docs/api/index.rst +53 -0
  12. iceberg_ops-0.1.0/docs/api/maintenance.rst +54 -0
  13. iceberg_ops-0.1.0/docs/api/migration.rst +6 -0
  14. iceberg_ops-0.1.0/docs/api/polaris.rst +48 -0
  15. iceberg_ops-0.1.0/docs/api/runner.rst +6 -0
  16. iceberg_ops-0.1.0/docs/api/scaffolding.rst +5 -0
  17. iceberg_ops-0.1.0/docs/api/schema.rst +6 -0
  18. iceberg_ops-0.1.0/docs/api/testing.rst +10 -0
  19. iceberg_ops-0.1.0/docs/api/types.rst +5 -0
  20. iceberg_ops-0.1.0/docs/api/utils.rst +5 -0
  21. iceberg_ops-0.1.0/docs/architecture.rst +189 -0
  22. iceberg_ops-0.1.0/docs/autogenerate.rst +281 -0
  23. iceberg_ops-0.1.0/docs/changelog.rst +7 -0
  24. iceberg_ops-0.1.0/docs/cli.rst +143 -0
  25. iceberg_ops-0.1.0/docs/compatibility.rst +298 -0
  26. iceberg_ops-0.1.0/docs/conf.py +214 -0
  27. iceberg_ops-0.1.0/docs/data_migration.rst +360 -0
  28. iceberg_ops-0.1.0/docs/index.rst +145 -0
  29. iceberg_ops-0.1.0/docs/maintenance.rst +262 -0
  30. iceberg_ops-0.1.0/docs/polaris.rst +293 -0
  31. iceberg_ops-0.1.0/docs/quickstart.rst +239 -0
  32. iceberg_ops-0.1.0/docs/schema_migrations.rst +565 -0
  33. iceberg_ops-0.1.0/iceberg_ops/__init__.py +12 -0
  34. iceberg_ops-0.1.0/iceberg_ops/autogen.py +1462 -0
  35. iceberg_ops-0.1.0/iceberg_ops/cli.py +1883 -0
  36. iceberg_ops-0.1.0/iceberg_ops/core/__init__.py +23 -0
  37. iceberg_ops-0.1.0/iceberg_ops/core/catalog.py +508 -0
  38. iceberg_ops-0.1.0/iceberg_ops/core/schema.py +1254 -0
  39. iceberg_ops-0.1.0/iceberg_ops/core/types.py +214 -0
  40. iceberg_ops-0.1.0/iceberg_ops/core/utils.py +561 -0
  41. iceberg_ops-0.1.0/iceberg_ops/data_migration/__init__.py +94 -0
  42. iceberg_ops-0.1.0/iceberg_ops/data_migration/checkpoint.py +454 -0
  43. iceberg_ops-0.1.0/iceberg_ops/data_migration/migrator.py +1564 -0
  44. iceberg_ops-0.1.0/iceberg_ops/data_migration/reconciler.py +594 -0
  45. iceberg_ops-0.1.0/iceberg_ops/history.py +409 -0
  46. iceberg_ops-0.1.0/iceberg_ops/maintenance/__init__.py +72 -0
  47. iceberg_ops-0.1.0/iceberg_ops/maintenance/advisor.py +194 -0
  48. iceberg_ops-0.1.0/iceberg_ops/maintenance/compaction.py +647 -0
  49. iceberg_ops-0.1.0/iceberg_ops/maintenance/orphans.py +455 -0
  50. iceberg_ops-0.1.0/iceberg_ops/maintenance/snapshots.py +242 -0
  51. iceberg_ops-0.1.0/iceberg_ops/migration.py +950 -0
  52. iceberg_ops-0.1.0/iceberg_ops/polaris/__init__.py +112 -0
  53. iceberg_ops-0.1.0/iceberg_ops/polaris/client.py +532 -0
  54. iceberg_ops-0.1.0/iceberg_ops/polaris/dispatcher.py +467 -0
  55. iceberg_ops-0.1.0/iceberg_ops/polaris/policies.py +216 -0
  56. iceberg_ops-0.1.0/iceberg_ops/py.typed +0 -0
  57. iceberg_ops-0.1.0/iceberg_ops/runner.py +827 -0
  58. iceberg_ops-0.1.0/iceberg_ops/scaffolding.py +303 -0
  59. iceberg_ops-0.1.0/iceberg_ops/testing.py +224 -0
  60. iceberg_ops-0.1.0/pyproject.toml +181 -0
  61. iceberg_ops-0.1.0/tests/conftest.py +103 -0
  62. iceberg_ops-0.1.0/tests/migrations/0001_create_products.py +26 -0
  63. iceberg_ops-0.1.0/tests/migrations/0002_add_sku.py +23 -0
  64. iceberg_ops-0.1.0/tests/migrations/__init__.py +0 -0
  65. iceberg_ops-0.1.0/tests/test_autogen.py +1927 -0
  66. iceberg_ops-0.1.0/tests/test_catalog.py +598 -0
  67. iceberg_ops-0.1.0/tests/test_cli.py +2115 -0
  68. iceberg_ops-0.1.0/tests/test_cli_catalogs.py +638 -0
  69. iceberg_ops-0.1.0/tests/test_cli_revision.py +421 -0
  70. iceberg_ops-0.1.0/tests/test_data_migration.py +3114 -0
  71. iceberg_ops-0.1.0/tests/test_docs_snippets.py +793 -0
  72. iceberg_ops-0.1.0/tests/test_history.py +357 -0
  73. iceberg_ops-0.1.0/tests/test_hygiene.py +137 -0
  74. iceberg_ops-0.1.0/tests/test_maintenance.py +1647 -0
  75. iceberg_ops-0.1.0/tests/test_migration.py +583 -0
  76. iceberg_ops-0.1.0/tests/test_packaging.py +368 -0
  77. iceberg_ops-0.1.0/tests/test_polaris_client.py +577 -0
  78. iceberg_ops-0.1.0/tests/test_polaris_dispatcher.py +611 -0
  79. iceberg_ops-0.1.0/tests/test_polaris_policies.py +175 -0
  80. iceberg_ops-0.1.0/tests/test_runner.py +852 -0
  81. iceberg_ops-0.1.0/tests/test_runner_concurrency.py +188 -0
  82. iceberg_ops-0.1.0/tests/test_scaffolding.py +196 -0
  83. iceberg_ops-0.1.0/tests/test_schema.py +1071 -0
  84. iceberg_ops-0.1.0/tests/test_smoke.py +18 -0
  85. iceberg_ops-0.1.0/tests/test_testing.py +127 -0
  86. iceberg_ops-0.1.0/tests/test_types.py +182 -0
  87. iceberg_ops-0.1.0/tests/test_utils.py +482 -0
@@ -0,0 +1,70 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ share/python-wheels/
20
+ *.egg-info/
21
+ .installed.cfg
22
+ *.egg
23
+ MANIFEST
24
+
25
+ # Virtual environments
26
+ .venv/
27
+ venv/
28
+ env/
29
+ ENV/
30
+ .env
31
+
32
+ # Testing / coverage
33
+ .pytest_cache/
34
+ .coverage
35
+ .coverage.*
36
+ htmlcov/
37
+ .tox/
38
+ .nox/
39
+ .cache/
40
+ coverage.xml
41
+ *.cover
42
+ .hypothesis/
43
+
44
+ # Type / lint caches
45
+ .mypy_cache/
46
+ .dmypy.json
47
+ dmypy.json
48
+ .pyre/
49
+ .pytype/
50
+ .ruff_cache/
51
+
52
+ # IDE
53
+ .idea/
54
+ .vscode/
55
+ *.swp
56
+ *.swo
57
+ *~
58
+
59
+ # OS
60
+ .DS_Store
61
+ Thumbs.db
62
+
63
+ # Sphinx
64
+ docs/_build/
65
+
66
+ # Jupyter
67
+ .ipynb_checkpoints/
68
+
69
+ # iceberg-ops runtime data
70
+ local_data/
@@ -0,0 +1,502 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-11-08
11
+
12
+ First public release of `iceberg-ops`: Alembic-style schema migrations
13
+ for Apache Iceberg, plus a cross-table data mover, basic maintenance
14
+ helpers and an experimental Apache Polaris policy executor.
15
+
16
+ Development builds from May 2026 were never published. The Changed and
17
+ Fixed lists below describe how this release differs from them, for
18
+ anyone who ran those builds from source.
19
+
20
+ ### Added
21
+
22
+ Schema migrations:
23
+
24
+ - `BaseMigration` and `MigrationContext` for `migrations/NNNN_*.py`
25
+ files with `upgrade()` / `downgrade()` and the `pre_upgrade`,
26
+ `post_upgrade`, `pre_downgrade` and `post_downgrade` hooks. Context
27
+ helpers: `create_table_if_not_exists` (with `partition_spec`,
28
+ `sort_order`, `properties` and `location`, applied on create),
29
+ `drop_table_if_exists`, `load_table`, `add_column`, `enforce_column`
30
+ (with opt-in `legacy_archival`), `enforce_rename`, `delete_column`,
31
+ `update_partition_spec`, `replace_sort_order`, `set_clustering_keys`,
32
+ `set_table_properties`, `unset_table_properties`,
33
+ `set_snapshot_retention` and `set_compaction_target_size`. Helpers
34
+ take bare table names; the runner adds the namespace.
35
+ - `MigrationRunner` with `migrate`, `rollback`, `stamp`, `plan`,
36
+ `status` and `history` for one namespace at a time. Migration history
37
+ is an Iceberg table, `_iceberg_ops.migration_history`, in the managed
38
+ catalog; it keeps the latest status of each version per namespace.
39
+ - `iceberg_ops.runner.normalize_version`: `"2"`, `"0002"` and `2` name
40
+ the same version.
41
+ - The `iceberg-ops` CLI: `init`, `revision`, `migrate`, `rollback`,
42
+ `stamp`, `plan`, `status`, `current`, `history`, `migrate-data` and
43
+ `run-policies`, plus `--version`. Settings come from flags or from
44
+ `iceberg-ops.yaml` (keys `catalog`, `catalog_name`, `namespace`,
45
+ `db_path`, `warehouse`, `migrations_dir`).
46
+ - `--catalog-name NAME` and the config key `catalog_name` open any
47
+ catalog defined in `.pyiceberg.yaml` or `PYICEBERG_CATALOG__<NAME>__*`
48
+ environment variables. `migrate-data` has `--src-catalog-name` /
49
+ `--dst-catalog-name` and `run-policies` has `--catalog-name`.
50
+ - Autogenerate (`iceberg_ops.autogen`): `schema_diff_to_ops`,
51
+ `spec_diff_to_ops`, `sort_order_diff_to_ops`, `properties_diff_to_ops`,
52
+ `render_migration_body`, `render_migration_file` and
53
+ `write_autogenerated_revision`, plus `iceberg-ops revision
54
+ --autogenerate --target-table T` with `--target-schema-yaml FILE` or
55
+ `--target-schema-arrow module:attr` and repeatable
56
+ `--rename-hint old:new`. The CLI diffs columns; the Python API also
57
+ diffs partition spec, sort order and table properties. Rename hints
58
+ are validated, and new columns keep their target doc.
59
+ - YAML schema contracts (`SchemaContract.from_yaml`) accept the Iceberg
60
+ primitive types, including `decimal(P,S)`, `fixed[N]` and
61
+ `timestamp_ns`, and are validated with clear errors.
62
+ - Core helpers (`iceberg_ops.core`): `CatalogFactory.build` for the
63
+ `sqlite`, `memory`, `glue`, `rest`, `hive` and `s3tables` kinds,
64
+ `default_local_paths()`, Arrow-to-Iceberg type translation with
65
+ `is_allowed_promotion(old, new)` (Arrow time, uuid, fixed-size binary,
66
+ string_view, binary_view and dictionary types are mapped), schema
67
+ diff, `SchemaContract`, `SchemaEvolutionHelper`, and snapshot, upsert,
68
+ time-travel and field-history utilities.
69
+
70
+ Companion modules:
71
+
72
+ - Cross-table data migration, experimental (`iceberg_ops.data_migration`,
73
+ `iceberg-ops migrate-data`): `SchemaReconciler`, `CheckpointStore` and
74
+ `DataMigrator` with `append`, `upsert` and `overwrite_partition` write
75
+ modes, checkpoint resume, dry runs and `row_count` /
76
+ `partition_hash` / `sample` verification. `MigrationResult` reports
77
+ `dest_snapshot_before` (a rollback target), `rows_inserted` and
78
+ `rows_updated`; `CheckpointStore.job` exposes the recorded job
79
+ settings. Every snapshot the migrator writes carries
80
+ `iceberg-ops.migration-job` and `iceberg-ops.migration-batch` summary
81
+ properties.
82
+ - Basic maintenance helpers for self-managed tables
83
+ (`iceberg_ops.maintenance`): `compact_small_files_streaming`,
84
+ `expire_snapshots`, `remove_orphan_files` and `table_health_report`.
85
+ `SnapshotExpiryResult.protected_ids` lists snapshots kept because a
86
+ branch or tag points at them; `OrphanCleanupResult.failed_paths` lists
87
+ orphans whose deletion failed.
88
+ - Apache Polaris policy executor, experimental (`iceberg_ops.polaris`,
89
+ `iceberg-ops run-policies`): `PolarisClient`, `Policy`,
90
+ `parse_policy`, `fetch_table_policies`, `dispatch_policy`,
91
+ `dispatch_policies` and a `handlers={...}` hook for custom policy
92
+ types. Only `system.snapshot-expiry` is executed.
93
+ - `iceberg_ops.testing`: `FakeResponse` and `FakeSession` test doubles
94
+ for driving the Polaris client without HTTP. They may change before
95
+ 1.0.
96
+
97
+ Packaging and project:
98
+
99
+ - Extras: `sqlite`, `glue`, `hive`, `s3tables`, `transforms` (needed to
100
+ write to tables partitioned by bucket, truncate or time transforms)
101
+ and `polaris`. `rest` and `contracts` are accepted but add nothing to
102
+ the core install.
103
+ - A `py.typed` marker, so type checkers use the package's annotations.
104
+ - Documentation at https://namitkewat.github.io/iceberg-ops/, including
105
+ a compatibility page and plain-text `llms.txt` / `llms-full.txt`
106
+ exports of the docs.
107
+ - `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` (Contributor Covenant 3.0),
108
+ `SECURITY.md` (GitHub private vulnerability reporting), `ROADMAP.md`,
109
+ issue forms and a pull request template.
110
+ - `AGENTS.md`, a guide for coding agents working on the repository, and
111
+ `agent-skills/iceberg-ops-migrations/SKILL.md`, a skill that teaches a
112
+ coding agent to write, review and run iceberg-ops migrations.
113
+
114
+ ### Changed
115
+
116
+ Packaging and CLI:
117
+
118
+ - The command-line tool is `iceberg-ops` only, and its config file is
119
+ `iceberg-ops.yaml`. The earlier short script name and its config file
120
+ name were dropped: that name belongs to an unrelated project.
121
+ - PyIceberg is pinned to `>=0.12.0,<0.13` (was `>=0.11.0,<0.12`) and
122
+ PyArrow to `>=18`. Python 3.14 is tested in CI. PyYAML is a core
123
+ dependency, and `tzdata` is installed on Windows.
124
+ - The `[maintenance]` extra was removed: it only installed boto3, which
125
+ no module imported. `[s3tables]` installs `pyiceberg[rest-sigv4]`;
126
+ `[rest]` and `[polaris]` no longer request a `pyiceberg[rest]` extra,
127
+ which PyIceberg does not define.
128
+ - Development status is Alpha.
129
+ - Default local state: without `db_path` / `warehouse_dir`,
130
+ `CatalogFactory.build("sqlite")` uses `./local_data/` in the current
131
+ working directory at call time, and the CLI's sqlite catalog uses
132
+ `local_data/` next to the config file (or in the current directory
133
+ when there is none). `CatalogFactory.build("memory")` without
134
+ `warehouse_dir` writes data files to a fresh temporary directory.
135
+ - The `glue`, `rest`, `hive` and `s3tables` kinds are loaded through
136
+ `pyiceberg.catalog.load_catalog`, so `.pyiceberg.yaml` and
137
+ `PYICEBERG_CATALOG__<KIND>__*` variables apply; explicit arguments
138
+ win. `db_path` with a remote kind raises `ValueError`, and the local
139
+ kinds reject `uri` / `warehouse` keyword arguments.
140
+ - The CLI no longer accepts the memory catalog kind, which forgot
141
+ applied migrations after each command. Catalog-kind options are
142
+ case-insensitive, and a typo is a usage error.
143
+ - Relative paths in the config file (`db_path`, the sqlite `warehouse`,
144
+ `migrations_dir`) resolve against the config file's directory, and
145
+ `migrations/` defaults to sitting next to it. Paths given as flags
146
+ stay relative to the current directory.
147
+ - CLI exit codes: 2 for usage errors (bad flags or config, missing
148
+ catalog, namespace or migrations directory, invalid `--target` /
149
+ `--steps`); 1 for runtime failures, with a single `Error:` line. Set
150
+ `ICEBERG_OPS_TRACEBACK=1` to see the traceback.
151
+ - A missing migrations directory is an error (`FileNotFoundError` from
152
+ the runner, a usage error in the CLI) instead of reporting that all
153
+ migrations are up to date. An explicit `--config` path that does not
154
+ exist is a usage error, except for `init`.
155
+
156
+ Migrations:
157
+
158
+ - A migration's `version` must equal its file's `NNNN` prefix, and two
159
+ files with the same version are rejected. `revision` numbers new files
160
+ by the same `NNNN_<description>.py` rule the runner uses.
161
+ - `plan`, `status` and `history` no longer create the `_iceberg_ops`
162
+ namespace or the history table; the first write creates them.
163
+ - `--target` / `target=` is compared as a version number: `2` means
164
+ `0002`, and an unknown or malformed target raises `ValueError` (CLI:
165
+ exit 2) before anything is applied. `rollback(target="0000")` rolls
166
+ back every migration. `rollback --steps` must be 1 or more, defaults
167
+ to 1 and cannot be combined with `--target`.
168
+ - `enforce_column` adds a missing column, does nothing for an equivalent
169
+ type (only a changed doc is written), updates a promotable primitive
170
+ type in place, and otherwise raises
171
+ `pyiceberg.exceptions.ValidationError` unless `legacy_archival=True`.
172
+ Promotions follow Iceberg's rules, so string-to-binary and decimal
173
+ scale changes are rejected.
174
+ - `set_clustering_keys` sets only the Iceberg sort order, in one commit.
175
+ It no longer writes `snowflake.cluster-by` or `delta.clustering` table
176
+ properties.
177
+ - `set_snapshot_retention` logs a warning when the catalog is AWS S3
178
+ Tables, which manages snapshot retention itself.
179
+ - `ConcurrentMigrationError` is defined in `iceberg_ops.history` and is
180
+ still importable from `iceberg_ops.runner`.
181
+ - `revision` never overwrites an existing migration file (exit 1).
182
+ `write_autogenerated_revision` refuses to overwrite one unless
183
+ `overwrite=True`, and always writes LF line endings.
184
+
185
+ Autogenerate:
186
+
187
+ - A bare `--target-table` stays bare in the generated file, so the
188
+ migration applies to whichever `--namespace` it runs against. A dotted
189
+ name pins the file to that table and prints a warning.
190
+ - A generated `upgrade()` or `downgrade()` raises `NotImplementedError`
191
+ before any step runs when one of its steps cannot run or cannot be
192
+ undone (a column drop, a type widening, a partition-field removal, a
193
+ sort-order replacement, a property removal with an unknown old
194
+ value). The generated calls are kept as comments.
195
+ - Type-change downgrades are no longer generated as narrowing calls,
196
+ since Iceberg only widens types; they raise `NotImplementedError`.
197
+ - A non-strict `SchemaContract` (the default) keeps the table columns
198
+ it does not list, and the CLI reports them. Set `strict: true` to drop
199
+ them. Nullability and doc differences on existing columns are
200
+ reported, not emitted.
201
+
202
+ Data migration and maintenance:
203
+
204
+ - `write_mode="overwrite_partition"` needs a destination partitioned by
205
+ identity transforms only, and the migration must fit in one commit (at
206
+ most `batch_rows` source rows after the row filter). Anything else is
207
+ rejected before any write.
208
+ - Checkpoints record the job's settings (source and destination table
209
+ UUIDs, row filter, `batch_rows`, write mode, upsert keys and
210
+ reconciler). Resuming with different settings, a recreated
211
+ destination or a checkpoint without these settings raises
212
+ `CheckpointError`, which is now a `RuntimeError`. A completed
213
+ checkpoint is terminal: call `CheckpointStore.reset()` to run again.
214
+ - `MigrationResult.new_snapshots` lists every destination snapshot the
215
+ run created (up to three per upsert batch, or per 1,000-key sub-batch
216
+ for composite keys), and
217
+ `CheckpointStore.mark_committed` accepts a list of snapshot IDs.
218
+ - `DataMigrator` rejects blank or repeated `upsert_keys`, `upsert_keys`
219
+ with a write mode other than upsert, and upsert keys missing from the
220
+ projected destination schema. An upsert with
221
+ `on_missing_in_source="skip"` that would drop destination columns is
222
+ rejected; with `"null"`, the plan warns that matched keys get those
223
+ values overwritten with null. `SchemaReconciler.compute_plan` reports
224
+ a `column_mapping` key that is not a source column.
225
+ - `migrate-data --batch-rows` and `--sample-size` must be at least 1,
226
+ `--upsert-keys` ignores empty entries, and a missing destination table
227
+ explains that `migrate-data` does not create it.
228
+ - `remove_orphan_files` defaults to `dry_run=True`; pass
229
+ `dry_run=False` to delete. It refuses tables with `gc.enabled=false`
230
+ (in a dry run too), rejects a negative `older_than_hours` and `sweep`
231
+ entries that are absolute or escape the table location, and warns when
232
+ `older_than_hours` is below 24. `deleted` is True only when at least
233
+ one file was removed.
234
+ - All `expire_snapshots` arguments after `table` are keyword-only, and
235
+ `max_age_hours` accepts fractional hours. Omitted arguments fall back
236
+ to the table's `history.expire.max-snapshot-age-ms` /
237
+ `history.expire.min-snapshots-to-keep` properties (as written by
238
+ `set_snapshot_retention`), then to the Iceberg defaults of 5 days and
239
+ 1 snapshot. Invalid values raise `ValueError`. The fallback to
240
+ PyIceberg's internal `ExpireSnapshots` class for releases older than
241
+ 0.11 was removed.
242
+ - `compact_small_files_streaming` lost its unused `batch_rows`
243
+ parameter.
244
+
245
+ Polaris (experimental):
246
+
247
+ - Only `system.snapshot-expiry` is executed, and only with
248
+ `"enable": true` (a JSON boolean). `system.metadata-compaction` no
249
+ longer triggers a data-file rewrite; it, `system.data-compaction` and
250
+ `system.orphan-file-removal` are reported as skipped.
251
+ - `system.snapshot-expiry` reads the Polaris schema
252
+ (`config.max_snapshot_age_days`, `config.min_snapshot_to_keep`;
253
+ `max_ref_age_days` is ignored with a note). Legacy top-level `*_ms`
254
+ keys, unknown top-level keys, unknown schema versions and invalid
255
+ values fail the dispatch instead of running on defaults.
256
+ - A custom handler returning `None` yields empty details; any other
257
+ non-dict return marks the dispatch as failed.
258
+ - `run-policies` is marked `[experimental]` and prints a warning when
259
+ run, because its requests do not yet match the released Polaris
260
+ policy API.
261
+
262
+ ### Fixed
263
+
264
+ Migrations and CLI:
265
+
266
+ - Two concurrent `migrate` runs for the same namespace no longer leave
267
+ duplicate history rows on PyIceberg 0.12
268
+ (apache/iceberg-python#4194). The run that records second raises
269
+ `ConcurrentMigrationError`, and history keeps one row per version and
270
+ namespace. Existing duplicate rows are read as their latest status and
271
+ removed by the next write for that version.
272
+ - When a migration's own commit conflicts with a concurrent commit, it
273
+ raises `ConcurrentMigrationError` and records nothing, instead of
274
+ overwriting the other run's history with `failed`.
275
+ - `migrate --target 2` no longer applies every migration, `stamp
276
+ --target 2` no longer stamps all of them, `rollback --target 1` is no
277
+ longer a no-op, and `"2"` no longer also selects `0010`.
278
+ - `rollback(steps=0)` and a negative `steps` raise `ValueError` instead
279
+ of rolling back an unexpected number of migrations.
280
+ - `rollback` checks that every migration file it needs exists before
281
+ running any `downgrade()`. `status` lists history entries whose
282
+ migration file is missing.
283
+ - `enforce_column` is idempotent for struct, list and map columns.
284
+ - `enforce_column(..., legacy_archival=True)` archives only on a real
285
+ type incompatibility. Commit errors and failing contracts propagate
286
+ instead of archiving the column and adding an empty replacement.
287
+ - Archival, in `enforce_column` and in the `enforce_rename` collision
288
+ path, refuses columns used by the partition spec, sort order or
289
+ identifier fields. An archived required column becomes optional, so
290
+ later appends no longer fail. If archival or the rename fails part
291
+ way, the column is restored with its original doc and required flag;
292
+ if that restore also fails, a `RuntimeError` is raised.
293
+ - `enforce_rename(name, name)` is a no-op, and renaming across
294
+ different parent structs raises `ValueError`.
295
+ - `enforce_column` and `add_column` accept primitive PyArrow types such
296
+ as `pa.float64()` and `pa.string()`.
297
+ - Migration files that define a `@dataclass` (with `from __future__
298
+ import annotations`) load. Same-named files in different namespace
299
+ directories no longer collide. A broken migration file raises an error
300
+ that names the file, and the CLI prints it as one `Error:` line.
301
+ - `create_table_if_not_exists` works for nested namespaces such as
302
+ `sales.eu`, and returns the existing table if another process creates
303
+ it first.
304
+ - `set_table_properties` accepts a property key named `properties`.
305
+ - `revision --autogenerate` honours `--catalog-name`, the config's
306
+ `catalog_name` and the config-relative sqlite defaults.
307
+ - Missing extras, missing tables, missing catalog properties,
308
+ connection failures, permission or server errors, commit conflicts and
309
+ corrupt checkpoints print a single `Error:` line and exit 1 instead of
310
+ a traceback.
311
+ - Config file problems (invalid YAML, a non-mapping top level,
312
+ non-string values such as `namespace: 123`) are usage errors. Unknown
313
+ keys print a warning.
314
+ - Config and generated files are read and written as UTF-8 (a BOM is
315
+ tolerated). `init` appends its block to `.gitignore` without rewriting
316
+ the existing content, and honours `--config` and `--migrations-dir`.
317
+ - Using the `s3tables` kind without boto3 says to install
318
+ `iceberg-ops[s3tables]`.
319
+ - `CatalogFactory.build(..., reset=True)` refuses to delete a
320
+ filesystem root, the home directory, the working directory or one of
321
+ its parents, or a database file that is not SQLite.
322
+ - The `sqlite` and `memory` kinds reject a warehouse that looks like a
323
+ URI and local paths containing `#` or `?`, which PyIceberg would
324
+ misread, before anything is written.
325
+ - The history table is created with `commit.retry.num-retries=20`, so
326
+ runners for different namespaces sharing it no longer lose history
327
+ writes to commit contention.
328
+ - When recording a failure in history itself fails, the migration's
329
+ own error is still the one raised; the history error is logged.
330
+ - `history` sizes its columns to their contents, so full ISO-8601
331
+ timestamps no longer push DESCRIPTION out of line, and a multi-line
332
+ error is printed on one line.
333
+ - `create_table_if_not_exists` raises `ValueError` before creating
334
+ anything when a `pa.Schema` is combined with a non-empty
335
+ `partition_spec` or `sort_order`.
336
+ - `SchemaContract.validate` compares struct, list and map types without
337
+ regard to field IDs, and `enforce_rename` checks the contract when
338
+ neither column exists.
339
+
340
+ Autogenerate:
341
+
342
+ - `revision --autogenerate` passes the table's partition spec, sort
343
+ order and format version to the diff. Dropping a column that the
344
+ spec, the sort order or the identifier fields use makes the generated
345
+ `upgrade()` raise before any step runs, and a type the table's format
346
+ version cannot hold is a usage error. Column names containing `.`,
347
+ decimals with a scale outside `0..precision` or more than 38 digits,
348
+ and `fixed[0]` are rejected too. Existing nested columns of a
349
+ `--target-schema-arrow` target that were not compared are named in a
350
+ warning instead of reporting no differences.
351
+ - Partition and sort fields sourced from a nested column render as the
352
+ full dotted path and bind to the nested field.
353
+ - Autogenerate no longer emits type changes that Iceberg does not
354
+ allow, such as a decimal scale change, string to long, or binary to
355
+ string; such a step makes `upgrade()` raise before anything runs.
356
+ - A generated `downgrade()` with an irreversible step no longer rolls
357
+ back part of the migration and then fails.
358
+ - Nested type changes (struct, list, map) in a `pyiceberg.schema.Schema`
359
+ target (Python API) produce a file that compiles, with the step
360
+ blocked for manual completion, instead of a syntax error.
361
+ - Invalid YAML contracts, unknown types and bad rename hints make
362
+ `revision --autogenerate` exit with a usage error.
363
+ - Partition fields with non-identity transforms (day, bucket, ...)
364
+ import their transform and run. Sort-order diffs compare transform
365
+ and null order, and reject orders the generated call cannot express.
366
+ - `--target-schema-arrow` modules are importable from the current
367
+ directory when the CLI runs from the installed console script.
368
+
369
+ Data migration:
370
+
371
+ - `overwrite_partition` could delete rows written by an earlier batch
372
+ of the same migration; it is now limited to one commit (see Changed).
373
+ - A resume reads the source snapshot recorded in the checkpoint, so rows
374
+ added to the source after a crash are neither skipped nor duplicated.
375
+ `verify()` after a run compares against the snapshot that was
376
+ migrated.
377
+ - Re-running with a checkpoint that records a completed migration raises
378
+ `CheckpointError` instead of duplicating and dropping rows.
379
+ - A batch committed just before a crash, but not yet checkpointed, is
380
+ not written twice on resume.
381
+ - Pre-flight runs every check before it changes the destination, so
382
+ auto-added columns are no longer committed when a later check fails.
383
+ - Writing to a destination partitioned by bucket, truncate, year, month,
384
+ day or hour without `pyiceberg-core` fails before any write with an
385
+ `ImportError` naming `pip install 'iceberg-ops[transforms]'`.
386
+ - The first upsert after a schema-only change (for example an
387
+ auto-added column) no longer fails; an empty snapshot is committed
388
+ first, as a workaround for apache/iceberg-python#2467.
389
+ - Composite-key upserts with more than a few thousand keys per batch no
390
+ longer crash the interpreter (apache/iceberg-python#3508). Large
391
+ batches are split into sub-batches of 1,000 keys within one commit.
392
+ - When the source schema changed after the pinned snapshot, the plan
393
+ uses the schema at that snapshot.
394
+ - `verify(method="partition_hash")` works when a partition field's name
395
+ differs from its source column, and raises `NotImplementedError` for
396
+ non-identity partitions instead of reporting a mismatch (the CLI
397
+ prints `Verify error:` and exits 1).
398
+ - A resume raises `CheckpointError` when the destination was rolled
399
+ back past a checkpointed batch, instead of skipping that batch's
400
+ rows. Every refusal that follows committed batches names the
401
+ snapshot to roll back to before `reset()`.
402
+ - Upsert keys that the plan would fill with null are rejected before
403
+ any write.
404
+ - `DataMigrator` and `migrate-data` refuse a source and destination
405
+ that are the same table.
406
+ - Checkpoint writes are fsync-ed before the atomic rename.
407
+
408
+ Maintenance:
409
+
410
+ - `remove_orphan_files` no longer deletes live position-delete,
411
+ equality-delete and deletion-vector files, or registered table and
412
+ partition statistics files.
413
+ - `compact_small_files_streaming` no longer deletes its staged files
414
+ when the commit outcome is unknown (for example
415
+ `CommitStateUnknownException`), which could make a committed table
416
+ unreadable.
417
+ - `expire_snapshots` skips branch and tag heads (including the current
418
+ snapshot after a rollback) instead of aborting, and dry runs select
419
+ the same snapshots as real runs. A max age older than the epoch
420
+ expires nothing instead of overflowing.
421
+ - Compaction and orphan removal accept Windows drive-letter table
422
+ locations.
423
+
424
+ ### Security
425
+
426
+ - Generated migration files cannot run injected code: the `-m`
427
+ message and column, partition and property names and values are
428
+ written as Python literals, and docstring text is escaped.
429
+ - Polaris OAuth2 token-endpoint errors no longer echo the response
430
+ payload, which could leak tokens to logs or stderr. The client strips
431
+ surrounding whitespace from a token, rejects an empty token or one
432
+ with inner whitespace or control characters (`run-policies` exits
433
+ 2), and keeps the token and client secret out of connection-error
434
+ messages.
435
+ - A failed migration's history row holds only `"<Type>: <message>"`;
436
+ the traceback is logged locally at `DEBUG` level, since everyone with
437
+ catalog access can read the history table.
438
+ - `remove_orphan_files` matches file names after Unicode NFC
439
+ normalisation and case folding, does not follow directory symlinks
440
+ or Windows junctions, and never deletes a file whose real path is
441
+ outside the table root.
442
+ - Compaction escapes identity-partition values the way PyIceberg does
443
+ and refuses to stage files outside `<table>/data`, so a partition
444
+ value such as `../../x` cannot write elsewhere.
445
+ - `run-policies` reads `ICEBERG_OPS_POLARIS_TOKEN`,
446
+ `ICEBERG_OPS_POLARIS_CLIENT_ID` and `ICEBERG_OPS_POLARIS_CLIENT_SECRET`
447
+ (preferred over flags, which show up in process listings) and warns
448
+ when `--polaris-url` uses plain http to a non-loopback host.
449
+
450
+ ### Known limitations
451
+
452
+ - Concurrent runs for one namespace are not guarded, only detected: a
453
+ second run can execute `upgrade()` before it fails to record history.
454
+ Run one migration job per namespace at a time and keep `upgrade()`
455
+ idempotent.
456
+ - The history table keeps the latest status of each version per
457
+ namespace, not one row per attempt. Earlier states are readable by
458
+ time travel on the history table until its snapshots expire.
459
+ - `status`, `current`, `plan` and `history` print only and exit 0 even
460
+ when a migration is recorded as failed; there is no `check` command
461
+ yet. The runner methods print their reports and return `None`.
462
+ - CLI autogenerate diffs columns only. Autogenerate does not emit
463
+ nullability or doc changes, blocks nested type changes from a
464
+ `pyiceberg.schema.Schema` target, and does not compare existing
465
+ nested columns of a `pyarrow.Schema` target (they are treated as
466
+ unchanged; the CLI names them in a warning). Each generated step commits on its own, so a failing step
467
+ leaves earlier steps applied.
468
+ - Only SQLite and in-memory catalogs are integration-tested. Glue, REST,
469
+ Hive and S3 Tables catalogs are reached through PyIceberg but have not
470
+ been tested against live services.
471
+ - Data migration: `overwrite_partition` is limited to one commit and
472
+ identity-partitioned destinations; the destination table must already
473
+ exist. `batch_rows` bounds the rows per commit, not peak memory, which
474
+ grows with the number and size of source data files
475
+ (apache/iceberg-python#2407); an upsert also loads the matched
476
+ destination rows. Duplicate keys within one batch raise `ValueError`,
477
+ and across batches the later row wins. `verify="sample"` can report a
478
+ false mismatch on larger tables, and `partition_hash` needs an
479
+ identity-partitioned or unpartitioned destination and reads both
480
+ tables in full.
481
+ - Maintenance: compaction and orphan removal support the local
482
+ filesystem only. Compaction supports identity partitions and
483
+ unpartitioned tables (other partitions are skipped), rewrites
484
+ qualifying partitions whole, writes `2N + 1` snapshots for `N`
485
+ partitions, uses PyArrow's default codec, fails when a concurrent
486
+ append lands in a partition being compacted, and its memory is not bounded by `target_size_mb`
487
+ (apache/iceberg-python#2407). Delete files and deletion vectors are
488
+ out of scope for compaction (there is no guard) and the health
489
+ report. Snapshot expiry is metadata-only.
490
+ - The Polaris executor does not call the Polaris 1.x policy endpoints,
491
+ so fetching policies from a released Polaris server fails. It has not
492
+ been validated against a live server.
493
+ - Python 3.15 is not tested. At release time PyIceberg 0.12.0 published
494
+ no Python 3.15 wheels.
495
+ - PyArrow 26 and later refuse to import next to NumPy 1.x. iceberg-ops
496
+ does not use NumPy; in an environment pinned to NumPy 1.x, also pin
497
+ `pyarrow<26`.
498
+ - On Windows, write a local warehouse in `.pyiceberg.yaml` as
499
+ `file://C:/path`; PyArrow rejects the `file:///C:/path` form.
500
+
501
+ [Unreleased]: https://github.com/namitkewat/iceberg-ops/compare/v0.1.0...HEAD
502
+ [0.1.0]: https://github.com/namitkewat/iceberg-ops/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Namit Kewat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.