Skip to content

Commit 8e61fd3

Browse files
author
Samson Gebre
committed
Update CHANGELOG.md with new features, changes, deprecations, and removals; modify prodev_quick_start.py for additional parameter; enhance pyproject.toml for migration tool; refine migrate_v0_to_v1.py documentation and usage instructions.
1 parent c2090f7 commit 8e61fd3

4 files changed

Lines changed: 93 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- `client.records.retrieve(table, record_id)` — fetch a single record by GUID; returns `None` on 404 instead of raising (#175)
12+
- `client.records.list(table, filter, select, top)` — eager fetch returning a flat `QueryResult`; GA replacement for `records.get()` without a record ID (#175)
13+
- `client.records.list_pages(table, filter, select, top)` — lazy iterator yielding one `QueryResult` per HTTP page; streaming counterpart to `list()` (#175)
14+
- `client.query.fetchxml(xml)` — FetchXML support returning an inert `FetchXmlQuery`; no HTTP request is made until `.execute()` or `.execute_pages()` is called (#175)
15+
- `FetchXmlQuery` implements the correct Dataverse paging cookie algorithm: annotation parsed as outer XML, `pagingcookie` attribute double URL-decoded, server-supplied `pagenumber` used for next page, `morerecords` handled as both `bool` and `"true"` string, `UserWarning` emitted on simple paging fallback, 32,768-character URL limit enforced (documented Dataverse GET cap), 10,000-page circuit breaker against runaway iteration (#175)
16+
- `QueryBuilder.execute_pages()` — lazy per-page streaming returning one `QueryResult` per HTTP page; replaces deprecated `execute(by_page=True)` (#175)
17+
- `QueryBuilder.where()` — composable filter expressions using `col()` and Python operators (`==`, `>`, `&`, `|`, `~`); replaces deprecated `filter_eq()`, `filter_contains()`, and other `filter_*` helpers (#175)
18+
- `QueryResult.__getitem__` — index access (`result[0]`) returns a `Record`; slice access (`result[1:5]`) returns a new `QueryResult` (#175)
19+
- `DataverseModel` structural `Protocol` (`models/protocol.py`) — implement on any entity class to enable typed integration with CRUD operations without specifying table names or serializing manually (#175)
20+
- `col()`, `raw()`, `QueryResult`, and `DataverseModel` exported from the top-level `PowerPlatform.Dataverse` package (#175)
21+
- v0→v1 migration tool: `tools/migrate_v0_to_v1.py` rewrites v0 call sites to the v1 API with `--dry-run` support; covers `create`, `update`, `delete`, `get`, `list`, `fetchxml`, and query builder patterns (#175)
22+
23+
### Changed
24+
- `QueryBuilder.execute()` now returns a flat `QueryResult` (all pages collected eagerly) instead of `Iterable[Record]` (#175)
25+
- `records.get()` deprecation extended: calling with a `record_id` emits `DeprecationWarning` directing callers to `retrieve()`; calling without a `record_id` directs callers to `list()` (#175)
26+
27+
### Deprecated
28+
- `QueryBuilder.execute(by_page=True)` and `execute(by_page=False)` emit `UserWarning`; use `execute_pages()` and `execute()` respectively (#175)
29+
- `client.query.odata_select()`, `client.query.odata_expands()`, `client.query.odata_expand()`, `client.query.odata_bind()` emit `DeprecationWarning`; navigation property helpers are replaced by `QueryBuilder.expand()` (#175)
30+
31+
### Removed
32+
- All v0 flat methods on `DataverseClient` (`create`, `update`, `delete`, `get`, `list`, `query_sql`, etc.) removed (~570 lines); use the `client.records`, `client.query`, and `client.batch` namespaces (#175)
33+
- `client.query.sql_select()`, `client.query.sql_joins()`, `client.query.sql_join()` removed (#175)
34+
1035
## [0.1.0b9] - 2026-04-28
1136

1237
### Added

examples/advanced/prodev_quick_start.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def run_demo(client):
116116
customer_ids, project_ids, task_ids = step3_populate_data(client, primary_name_col)
117117

118118
# -- Step 4: Query and analyze --
119-
step4_query_and_analyze(client, customer_ids, primary_name_col)
119+
step4_query_and_analyze(client, customer_ids, primary_name_col, primary_id_col)
120120

121121
# -- Step 5: Update and delete --
122122
step5_update_and_delete(client, task_ids, primary_name_col, primary_id_col)
@@ -385,7 +385,7 @@ def step3_populate_data(client, primary_name_col):
385385
# ================================================================
386386

387387

388-
def step4_query_and_analyze(client, customer_ids, primary_name_col):
388+
def step4_query_and_analyze(client, customer_ids, primary_name_col, primary_id_col):
389389
"""Query data and demonstrate DataFrame analysis."""
390390
print("\n" + "-" * 60)
391391
print("STEP 4: Query and analyze data")

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ dependencies = [
4040

4141
[project.scripts]
4242
dataverse-install-claude-skill = "PowerPlatform.Dataverse._skill_installer:main"
43+
dataverse-migrate = "tools.migrate_v0_to_v1:main"
4344

4445
[project.optional-dependencies]
4546
dev = [
@@ -53,12 +54,12 @@ dev = [
5354
migration = ["libcst>=1.0.0"]
5455

5556
[tool.setuptools]
56-
package-dir = {"" = "src"}
57+
package-dir = {"" = "src", "tools" = "tools"}
5758
zip-safe = false
5859

5960
[tool.setuptools.packages.find]
60-
where = ["src"]
61-
include = ["PowerPlatform*"]
61+
where = ["src", "."]
62+
include = ["PowerPlatform*", "tools"]
6263
namespaces = false
6364

6465
[tool.setuptools.package-data]

tools/migrate_v0_to_v1.py

Lines changed: 62 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,77 +3,80 @@
33
# Licensed under the MIT license.
44

55
"""
6-
DV-Python-SDK v0 v1 GA migration codemod.
6+
DV-Python-SDK v0 -> v1 GA migration codemod.
77
88
Mechanically rewrites beta (0.1.0b*) call sites to their GA (1.0) equivalents
99
using LibCST (concrete syntax tree — preserves all whitespace and comments).
1010
1111
Usage::
1212
1313
pip install PowerPlatform-Dataverse-Client[migration]
14-
python -m tools.migrate_v0_to_v1 path/to/scripts/
15-
python -m tools.migrate_v0_to_v1 examples/ # _codemon.py files only
14+
dataverse-migrate path/to/your/scripts/
15+
dataverse-migrate path/to/your/scripts/ --dry-run # preview changes without writing
16+
17+
# Or via module for development installs:
18+
python -m tools.migrate_v0_to_v1 path/to/your/scripts/
1619
1720
Transformations applied
1821
-----------------------
19-
Builder methods (.filter_* .where(col(...)...))::
20-
21-
.filter_eq("col", v) .where(col("col") == v)
22-
.filter_ne("col", v) .where(col("col") != v)
23-
.filter_gt("col", v) .where(col("col") > v)
24-
.filter_ge("col", v) .where(col("col") >= v)
25-
.filter_lt("col", v) .where(col("col") < v)
26-
.filter_le("col", v) .where(col("col") <= v)
27-
.filter_contains("col", v) .where(col("col").contains(v))
28-
.filter_startswith("col", v) .where(col("col").startswith(v))
29-
.filter_endswith("col", v) .where(col("col").endswith(v))
30-
.filter_in("col", vals) .where(col("col").in_(vals))
31-
.filter_not_in("col", vals) .where(col("col").not_in(vals))
32-
.filter_null("col") .where(col("col").is_null())
33-
.filter_not_null("col") .where(col("col").is_not_null())
34-
.filter_between("col", lo, hi) .where(col("col").between(lo, hi))
35-
.filter_not_between("col", lo, hi) .where(col("col").not_between(lo, hi))
36-
.filter_raw("expr") .where(raw("expr"))
37-
.filter("expr") .where(raw("expr"))
38-
.execute(by_page=True) .execute_pages()
39-
.execute(by_page=False) .execute() (flag removed)
22+
Builder methods (.filter_* -> .where(col(...)...))::
23+
24+
.filter_eq("col", v) -> .where(col("col") == v)
25+
.filter_ne("col", v) -> .where(col("col") != v)
26+
.filter_gt("col", v) -> .where(col("col") > v)
27+
.filter_ge("col", v) -> .where(col("col") >= v)
28+
.filter_lt("col", v) -> .where(col("col") < v)
29+
.filter_le("col", v) -> .where(col("col") <= v)
30+
.filter_contains("col", v) -> .where(col("col").contains(v))
31+
.filter_startswith("col", v) -> .where(col("col").startswith(v))
32+
.filter_endswith("col", v) -> .where(col("col").endswith(v))
33+
.filter_in("col", vals) -> .where(col("col").in_(vals))
34+
.filter_not_in("col", vals) -> .where(col("col").not_in(vals))
35+
.filter_null("col") -> .where(col("col").is_null())
36+
.filter_not_null("col") -> .where(col("col").is_not_null())
37+
.filter_between("col", lo, hi) -> .where(col("col").between(lo, hi))
38+
.filter_not_between("col", lo, hi) -> .where(col("col").not_between(lo, hi))
39+
.filter_raw("expr") -> .where(raw("expr"))
40+
.filter("expr") -> .where(raw("expr"))
41+
.execute(by_page=True) -> .execute_pages()
42+
.execute(by_page=False) -> .execute() (flag removed)
4043
4144
Record namespace::
4245
43-
batch.records.get(t, id) batch.records.retrieve(t, id)
46+
batch.records.get(t, id) -> batch.records.retrieve(t, id)
4447
4548
Top-level shortcuts (removed at GA)::
4649
47-
client.create(t, d) client.records.create(t, d)
48-
client.update(t, id, d) client.records.update(t, id, d)
49-
client.delete(t, id) client.records.delete(t, id)
50-
client.get(t, id) client.records.retrieve(t, id)
51-
client.query_sql(sql) client.query.sql(sql)
52-
client.get_table_info(t) client.tables.get(t)
53-
client.create_table(t, …) client.tables.create(t, …)
54-
client.delete_table(t) client.tables.delete(t)
55-
client.list_tables() client.tables.list()
56-
client.create_columns(t, …) client.tables.add_columns(t, …)
57-
client.delete_columns(t, …) client.tables.remove_columns(t, …)
58-
client.upload_file(…) client.files.upload(…)
50+
client.create(t, d) -> client.records.create(t, d)
51+
client.update(t, id, d) -> client.records.update(t, id, d)
52+
client.delete(t, id) -> client.records.delete(t, id)
53+
client.get(t, id) -> client.records.retrieve(t, id)
54+
client.query_sql(sql) -> client.query.sql(sql)
55+
client.get_table_info(t) -> client.tables.get(t)
56+
client.create_table(t, …) -> client.tables.create(t, …)
57+
client.delete_table(t) -> client.tables.delete(t)
58+
client.list_tables() -> client.tables.list()
59+
client.create_columns(t, …) -> client.tables.add_columns(t, …)
60+
client.delete_columns(t, …) -> client.tables.remove_columns(t, …)
61+
client.upload_file(…) -> client.files.upload(…)
5962
6063
Import management:
6164
Adds ``from PowerPlatform.Dataverse.models.filters import col`` when a
6265
.filter_* method is rewritten (if col is not already imported).
6366
Adds ``raw`` to the same import when .filter_raw or .filter is rewritten.
6467
6568
NOT handled by this codemod (manual migration required):
66-
execute(by_page=variable) manual review required (variable argument, not literal)
67-
client.records.get(t, id) client.records.retrieve(t, id)
69+
execute(by_page=variable) -> manual review required (variable argument, not literal)
70+
client.records.get(t, id) -> client.records.retrieve(t, id)
6871
Return type changes: beta returns Record (raises on 404); GA retrieve() returns
6972
Record | None. Callers that do not guard against None will fail silently.
70-
client.records.get(t, kw=…) client.records.list(t, kw=…)
73+
client.records.get(t, kw=…) -> client.records.list(t, kw=…)
7174
Return type changes: beta returns Iterable[List[Record]] (pages); GA list()
7275
returns QueryResult (flat iterable over Records). Any ``for page in result:
7376
for rec in page:`` iteration pattern breaks after a mechanical rename.
74-
client.dataframe.get() client.query.builder(…).execute().to_dataframe()
77+
client.dataframe.get() -> client.query.builder(…).execute().to_dataframe()
7578
Expression reconstruction requires understanding caller intent.
76-
client.query.sql_select()/sql_join()/sql_joins() removed (no mechanical replacement)
79+
client.query.sql_select()/sql_join()/sql_joins() -> removed (no mechanical replacement)
7780
"""
7881

7982
from __future__ import annotations
@@ -95,7 +98,7 @@
9598

9699

97100
# ---------------------------------------------------------------------------
98-
# Filter-method .where(col(...)) mapping
101+
# Filter-method -> .where(col(...)) mapping
99102
# ---------------------------------------------------------------------------
100103

101104
_UNARY_FILTER_MAP = {
@@ -124,8 +127,8 @@
124127

125128
_ALL_FILTER_METHODS: Set[str] = set(_UNARY_FILTER_MAP) | set(_BINARY_OP_MAP) | set(_METHOD_FILTER_MAP) | {"filter_raw"}
126129

127-
# Standalone filter functions from filters module (beta API) col() equivalents
128-
# eq("f", v) col("f") == v, between("f", lo, hi) col("f").between(lo, hi), etc.
130+
# Standalone filter functions from filters module (beta API) -> col() equivalents
131+
# eq("f", v) -> col("f") == v, between("f", lo, hi) -> col("f").between(lo, hi), etc.
129132
_FUNC_BINARY_OP_MAP = {
130133
"eq": cst.Equal(),
131134
"ne": cst.NotEqual(),
@@ -149,7 +152,7 @@
149152
}
150153
_ALL_FILTER_FUNCS: Set[str] = set(_FUNC_BINARY_OP_MAP) | set(_FUNC_METHOD_MAP) | set(_FUNC_UNARY_MAP)
151154

152-
# Top-level client shortcut (new_namespace, new_method)
155+
# Top-level client shortcut -> (new_namespace, new_method)
153156
_CLIENT_SHORTCUTS = {
154157
"create": ("records", "create"),
155158
"update": ("records", "update"),
@@ -233,7 +236,7 @@ def _positional_count(args: Sequence[cst.Arg]) -> int:
233236

234237

235238
class _V1Migrator(cst.CSTTransformer):
236-
"""LibCST transformer rewriting DV-Python-SDK beta v1 GA."""
239+
"""LibCST transformer rewriting DV-Python-SDK beta -> v1 GA."""
237240

238241
def __init__(self, client_var: str = "client") -> None:
239242
self._client_var = client_var
@@ -271,7 +274,7 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Bas
271274
func = updated_node.func
272275

273276
# ----------------------------------------------------------------
274-
# Standalone filter functions: eq("f", v) col("f") == v, etc.
277+
# Standalone filter functions: eq("f", v) -> col("f") == v, etc.
275278
# Only transform names that were actually imported from filters module.
276279
# Wrap Comparison nodes in explicit parentheses so that combining with
277280
# & / | doesn't hit Python precedence bugs (& binds tighter than ==/>).
@@ -289,7 +292,7 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Bas
289292
method_name = func.attr.value if isinstance(func.attr, cst.Name) else ""
290293

291294
# ----------------------------------------------------------------
292-
# .filter_*(...) .where(col(...) ...)
295+
# .filter_*(...) -> .where(col(...) ...)
293296
# ----------------------------------------------------------------
294297
if method_name in _ALL_FILTER_METHODS:
295298
where_arg = self._build_filter_arg(method_name, updated_node.args)
@@ -300,7 +303,7 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Bas
300303
)
301304

302305
# ----------------------------------------------------------------
303-
# .filter("expr") .where(raw("expr"))
306+
# .filter("expr") -> .where(raw("expr"))
304307
# QueryBuilder.filter() was removed at GA (not deprecated). Wrapping
305308
# in raw() preserves the OData string exactly for string-literal callers.
306309
# ----------------------------------------------------------------
@@ -314,8 +317,8 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Bas
314317
)
315318

316319
# ----------------------------------------------------------------
317-
# .execute(by_page=True) .execute_pages()
318-
# .execute(by_page=False) .execute() (flag removed)
320+
# .execute(by_page=True) -> .execute_pages()
321+
# .execute(by_page=False) -> .execute() (flag removed)
319322
# Only literal True/False are codemod-able; variable by_page requires
320323
# manual review per section 8.5 of the GA spec.
321324
# ----------------------------------------------------------------
@@ -335,7 +338,7 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Bas
335338
return updated_node.with_changes(args=other_args)
336339

337340
# ----------------------------------------------------------------
338-
# batch.records.get(table, id) batch.records.retrieve(table, id)
341+
# batch.records.get(table, id) -> batch.records.retrieve(table, id)
339342
# NOTE: client.records.get() is NOT codemodded — the return type changes
340343
# between beta and GA (Record | None vs Record for single-id; QueryResult vs
341344
# Iterable[List[Record]] for multi-record). Surrounding iteration patterns
@@ -394,18 +397,18 @@ def _build_filter_arg(
394397
if field_node is None:
395398
return None
396399

397-
# .filter_raw(expr) raw(expr)
400+
# .filter_raw(expr) -> raw(expr)
398401
if method_name == "filter_raw":
399402
self._needs_raw = True
400403
return _call(_name("raw"), field_node)
401404

402-
# .filter_null / .filter_not_null col("f").is_null() / .is_not_null()
405+
# .filter_null / .filter_not_null -> col("f").is_null() / .is_not_null()
403406
if method_name in _UNARY_FILTER_MAP:
404407
self._needs_col = True
405408
proxy = _UNARY_FILTER_MAP[method_name]
406409
return _call(_attr(_col_call(field_node), proxy))
407410

408-
# .filter_eq / .filter_ne / ... col("f") OP val
411+
# .filter_eq / .filter_ne / ... -> col("f") OP val
409412
if method_name in _BINARY_OP_MAP:
410413
val_node = _pos_arg(args, 1)
411414
if val_node is None:
@@ -421,7 +424,7 @@ def _build_filter_arg(
421424
],
422425
)
423426

424-
# .filter_between / .filter_not_between col("f").between(lo, hi)
427+
# .filter_between / .filter_not_between -> col("f").between(lo, hi)
425428
if method_name in ("filter_between", "filter_not_between"):
426429
lo = _pos_arg(args, 1)
427430
hi = _pos_arg(args, 2)
@@ -443,7 +446,7 @@ def _build_filter_arg(
443446
return None
444447

445448
# ------------------------------------------------------------------
446-
# Standalone filter function: eq("f", v) col("f") == v, etc.
449+
# Standalone filter function: eq("f", v) -> col("f") == v, etc.
447450
# ------------------------------------------------------------------
448451

449452
def _build_filter_func_arg(
@@ -642,7 +645,7 @@ def main(argv: Optional[List[str]] = None) -> int:
642645

643646
if not remaining:
644647
print(__doc__)
645-
print("\nUsage: python -m tools.migrate_v0_to_v1 [--dry-run] <path> [<path> ...]")
648+
print("\nUsage: dataverse-migrate [--dry-run] <path> [<path> ...]")
646649
return 1
647650

648651
targets = _collect_targets(remaining)

0 commit comments

Comments
 (0)