honua-admin 0.2.0__tar.gz → 0.2.2__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.
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.2](https://github.com/honua-io/honua-sdk-python/compare/python-admin-v0.2.1...python-admin-v0.2.2) (2026-11-07)
4
+
5
+
6
+ ### Features
7
+
8
+ * page the admin service and layer lists ([#362](https://github.com/honua-io/honua-sdk-python/issues/362)) ([eee2d3e](https://github.com/honua-io/honua-sdk-python/commit/eee2d3e4a898bdb53753bdd6d45ef471193f46b2))
9
+
10
+
11
+ ### Documentation
12
+
13
+ * neutral wording in honua-gp docstrings and docs ([#356](https://github.com/honua-io/honua-sdk-python/issues/356)) ([08d4113](https://github.com/honua-io/honua-sdk-python/commit/08d4113a801ce22b60463ae021dc843a4dc21caa))
14
+ * neutralize Python client and geometry terminology ([#367](https://github.com/honua-io/honua-sdk-python/issues/367)) ([5f8b245](https://github.com/honua-io/honua-sdk-python/commit/5f8b245179550d1799a09a0efa1154604291e86a))
15
+
16
+ ## [0.2.1](https://github.com/honua-io/honua-sdk-python/compare/python-admin-v0.2.0...python-admin-v0.2.1) (2026-11-04)
17
+
18
+
19
+ ### Documentation
20
+
21
+ * show how to verify a release's build provenance ([#354](https://github.com/honua-io/honua-sdk-python/issues/354)) ([712beb1](https://github.com/honua-io/honua-sdk-python/commit/712beb11be58d4f0378b424dff3903dab56b2eaf))
22
+
3
23
  ## [0.2.0](https://github.com/honua-io/honua-sdk-python/compare/python-admin-v0.1.13...python-admin-v0.2.0) (2026-11-04)
4
24
 
5
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: honua-admin
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Admin / control-plane client for Honua geospatial server
5
5
  Project-URL: Homepage, https://honua.io
6
6
  Project-URL: Documentation, https://honua-io.github.io/honua-sdk-python/latest/
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Scientific/Engineering :: GIS
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.11
24
- Requires-Dist: honua-sdk<0.3,>=0.2.0
24
+ Requires-Dist: honua-sdk<0.3,>=0.2.2
25
25
  Description-Content-Type: text/markdown
26
26
 
27
27
  # honua-admin
@@ -46,9 +46,9 @@ and manage server state declaratively through manifests. Sync
46
46
  contract, so callers can gate control-plane calls on what the connected
47
47
  server actually supports (`manifest_apply`, `manifest_dry_run`,
48
48
  `metadata_resources`, ...).
49
- - **Services**: `list_services()`, `get_service_settings()`,
49
+ - **Services**: `list_services()`, `list_services_page()`, `get_service_settings()`,
50
50
  `update_protocols()`, `update_mapserver_settings()`.
51
- - **Layers and data**: `list_layers()`, `publish_layer()`,
51
+ - **Layers and data**: `list_layers()`, `list_layers_page()`, `publish_layer()`,
52
52
  `set_layer_enabled()` / `set_service_layers_enabled()`, and
53
53
  `discover_tables()` for source-database table discovery.
54
54
  - **Connections**: typed CRUD plus `test_connection()` /
@@ -136,6 +136,17 @@ Related: [honua-sdk](https://github.com/honua-io/honua-sdk-python/tree/trunk/pac
136
136
  [honua-sdk-js](https://github.com/honua-io/honua-sdk-js) (JS/TS SDKs + MCP server),
137
137
  [honua-sdk-dotnet](https://github.com/honua-io/honua-sdk-dotnet) (.NET SDKs).
138
138
 
139
+ ## Verify a release
140
+
141
+ From 0.2.1, each PyPI release is built by this repository's publication workflow, which attaches
142
+ the same wheel and sdist to the matching GitHub release and records a build-provenance attestation
143
+ for them. 0.2.0 was published without an attestation; use 0.2.1 or later when you need to verify
144
+ provenance. To check a downloaded file:
145
+
146
+ ```bash
147
+ gh attestation verify honua_admin-<version>-py3-none-any.whl --repo honua-io/honua-sdk-python
148
+ ```
149
+
139
150
  ## License
140
151
 
141
152
  Apache-2.0
@@ -20,9 +20,9 @@ and manage server state declaratively through manifests. Sync
20
20
  contract, so callers can gate control-plane calls on what the connected
21
21
  server actually supports (`manifest_apply`, `manifest_dry_run`,
22
22
  `metadata_resources`, ...).
23
- - **Services**: `list_services()`, `get_service_settings()`,
23
+ - **Services**: `list_services()`, `list_services_page()`, `get_service_settings()`,
24
24
  `update_protocols()`, `update_mapserver_settings()`.
25
- - **Layers and data**: `list_layers()`, `publish_layer()`,
25
+ - **Layers and data**: `list_layers()`, `list_layers_page()`, `publish_layer()`,
26
26
  `set_layer_enabled()` / `set_service_layers_enabled()`, and
27
27
  `discover_tables()` for source-database table discovery.
28
28
  - **Connections**: typed CRUD plus `test_connection()` /
@@ -110,6 +110,17 @@ Related: [honua-sdk](https://github.com/honua-io/honua-sdk-python/tree/trunk/pac
110
110
  [honua-sdk-js](https://github.com/honua-io/honua-sdk-js) (JS/TS SDKs + MCP server),
111
111
  [honua-sdk-dotnet](https://github.com/honua-io/honua-sdk-dotnet) (.NET SDKs).
112
112
 
113
+ ## Verify a release
114
+
115
+ From 0.2.1, each PyPI release is built by this repository's publication workflow, which attaches
116
+ the same wheel and sdist to the matching GitHub release and records a build-provenance attestation
117
+ for them. 0.2.0 was published without an attestation; use 0.2.1 or later when you need to verify
118
+ provenance. To check a downloaded file:
119
+
120
+ ```bash
121
+ gh attestation verify honua_admin-<version>-py3-none-any.whl --repo honua-io/honua-sdk-python
122
+ ```
123
+
113
124
  ## License
114
125
 
115
126
  Apache-2.0
@@ -13,6 +13,7 @@ except Exception: # pragma: no cover -- editable / not-installed fallback
13
13
  __version__ = "0.0.0.dev0"
14
14
 
15
15
  from ._async_client import AsyncHonuaAdminClient
16
+ from ._catalog import LayerListQuery, PublishedLayerPage, ServiceListPage, ServiceListQuery
16
17
  from ._client import HonuaAdminClient
17
18
  from ._models import (
18
19
  MINIMUM_SUPPORTED_CONTROL_PLANE_API_MAJOR,
@@ -132,6 +133,7 @@ __all__ = [
132
133
  "HonuaAdminClient",
133
134
  "ImportResult",
134
135
  "KeyRotationResult",
136
+ "LayerListQuery",
135
137
  "LayerStyleResponse",
136
138
  "LayerStyleUpdateRequest",
137
139
  "ManifestApplyEntry",
@@ -176,10 +178,13 @@ __all__ = [
176
178
  "OperationProposalDetail",
177
179
  "OperationProposalSummary",
178
180
  "PublishLayerRequest",
181
+ "PublishedLayerPage",
179
182
  "PublishedLayerSummary",
180
183
  "ResourceMetadata",
181
184
  "SecureConnectionDetail",
182
185
  "SecureConnectionSummary",
186
+ "ServiceListPage",
187
+ "ServiceListQuery",
183
188
  "ServiceSettingsResponse",
184
189
  "ServiceSummary",
185
190
  "StyleEncoding",
@@ -31,6 +31,7 @@ from honua_sdk.http import (
31
31
  )
32
32
 
33
33
  from . import _endpoints
34
+ from ._catalog import LayerListQuery, PublishedLayerPage, ServiceListPage, ServiceListQuery
34
35
  from ._models import (
35
36
  DEFAULT_STYLE_ENCODING as _DEFAULT_STYLE_ENCODING,
36
37
  )
@@ -512,9 +513,7 @@ class AsyncHonuaAdminClient:
512
513
  )
513
514
  return _endpoints.unwrap_envelope(response)
514
515
 
515
- # ======================================================================
516
516
  # File imports
517
- # ======================================================================
518
517
 
519
518
  async def upload_file(
520
519
  self,
@@ -568,9 +567,7 @@ class AsyncHonuaAdminClient:
568
567
  return BackgroundImportResponse.from_dict(payload)
569
568
  return ImportResult.from_dict(payload)
570
569
 
571
- # ======================================================================
572
570
  # Services
573
- # ======================================================================
574
571
 
575
572
  async def list_services(
576
573
  self,
@@ -578,30 +575,41 @@ class AsyncHonuaAdminClient:
578
575
  timeout: float | httpx.Timeout | None = None,
579
576
  extra_headers: Mapping[str, str] | None = None,
580
577
  ) -> list[ServiceSummary]:
581
- """List every service registered on the admin catalog.
582
-
583
- Returns:
584
- Typed :class:`ServiceSummary` objects for each service the
585
- server reports. Empty when the server returns a non-list
586
- payload (e.g. an empty envelope).
587
-
588
- Per-request options (``timeout`` / ``extra_headers``) are forwarded
589
- to :meth:`_request`.
578
+ """Read every page of the admin service list."""
579
+ collected: list[ServiceSummary] = []
580
+ page_number = _endpoints.DEFAULT_CATALOG_PAGE
581
+ while True:
582
+ page = await self.list_services_page(
583
+ ServiceListQuery(page=page_number), timeout=timeout, extra_headers=extra_headers
584
+ )
585
+ advanced = _endpoints._advance_catalog_page(collected, page)
586
+ if advanced is None:
587
+ return collected
588
+ page_number = advanced
590
589
 
591
- Raises:
592
- HonuaHttpError: The server responded with a non-success status.
593
- HonuaTransportError: The request failed before any HTTP
594
- response was received.
595
- """
596
- data = await self._request_json(
597
- "GET",
598
- "/api/v1/admin/services/",
599
- timeout=timeout,
600
- extra_headers=extra_headers,
590
+ async def list_services_page(
591
+ self,
592
+ query: ServiceListQuery | None = None,
593
+ *,
594
+ timeout: float | httpx.Timeout | None = None,
595
+ extra_headers: Mapping[str, str] | None = None,
596
+ ) -> ServiceListPage:
597
+ """Return one page of admin services and the count of every match."""
598
+ selected = query or ServiceListQuery()
599
+ return ServiceListPage.from_dict(
600
+ await self._request_json(
601
+ "GET",
602
+ "/api/v1/admin/services/",
603
+ params=_endpoints.admin_catalog_params(
604
+ page=selected.page,
605
+ page_size=selected.page_size,
606
+ name_query=selected.name_query,
607
+ extra=(("folderId", selected.folder_id),),
608
+ ),
609
+ timeout=timeout,
610
+ extra_headers=extra_headers,
611
+ )
601
612
  )
602
- if not isinstance(data, list):
603
- return []
604
- return [ServiceSummary.from_dict(s) for s in data]
605
613
 
606
614
  async def get_service_settings(
607
615
  self,
@@ -786,9 +794,7 @@ class AsyncHonuaAdminClient:
786
794
  )
787
795
  return ServiceSettingsResponse.from_dict(data)
788
796
 
789
- # ======================================================================
790
797
  # Metadata Resources
791
- # ======================================================================
792
798
 
793
799
  async def list_metadata_resources(
794
800
  self,
@@ -1004,9 +1010,7 @@ class AsyncHonuaAdminClient:
1004
1010
  idempotency_key=idempotency_key,
1005
1011
  )
1006
1012
 
1007
- # ======================================================================
1008
1013
  # Manifests
1009
- # ======================================================================
1010
1014
 
1011
1015
  async def get_version(
1012
1016
  self,
@@ -1198,9 +1202,7 @@ class AsyncHonuaAdminClient:
1198
1202
  )
1199
1203
  return ManifestApplyResult.from_dict(data)
1200
1204
 
1201
- # ======================================================================
1202
1205
  # Migration Toolkit
1203
- # ======================================================================
1204
1206
 
1205
1207
  async def scan_migration_source(
1206
1208
  self,
@@ -1234,12 +1236,12 @@ class AsyncHonuaAdminClient:
1234
1236
  ) -> ToolboxTranslationReport:
1235
1237
  """POST /api/v1/admin/import/toolbox/translation/validate
1236
1238
 
1237
- Validate an SDK-translated ArcGIS toolbox manifest against the server's
1239
+ Validate an SDK-translated source toolbox manifest against the server's
1238
1240
  canonical geoprocessing process catalog, and return the
1239
1241
  server-authoritative per-tool classification.
1240
1242
 
1241
1243
  The server owns the round-trip proof: it never parses toolbox sources
1242
- and never emulates ``arcpy``. The SDK proposes a mapping; the catalog
1244
+ and never emulates the scripting API. The SDK proposes a mapping; the catalog
1243
1245
  decides whether that mapping is ``translated``,
1244
1246
  ``partially-translated``, or ``unsupported``, and returns the specific
1245
1247
  reasons a tool cannot be fully translated.
@@ -1270,9 +1272,7 @@ class AsyncHonuaAdminClient:
1270
1272
  )
1271
1273
  return ToolboxTranslationReport.from_dict(data)
1272
1274
 
1273
- # ======================================================================
1274
1275
  # Connections
1275
- # ======================================================================
1276
1276
 
1277
1277
  async def list_connections(
1278
1278
  self,
@@ -1561,9 +1561,7 @@ class AsyncHonuaAdminClient:
1561
1561
  )
1562
1562
  return KeyRotationResult.from_dict(data)
1563
1563
 
1564
- # ======================================================================
1565
1564
  # Layers
1566
- # ======================================================================
1567
1565
 
1568
1566
  async def list_layers(
1569
1567
  self,
@@ -1573,37 +1571,45 @@ class AsyncHonuaAdminClient:
1573
1571
  timeout: float | httpx.Timeout | None = None,
1574
1572
  extra_headers: Mapping[str, str] | None = None,
1575
1573
  ) -> list[PublishedLayerSummary]:
1576
- """List published layers belonging to a connection.
1577
-
1578
- Args:
1579
- conn_id: Connection identifier; URL-encoded.
1580
- service_name: When provided, restrict to one service's layers.
1581
-
1582
- Returns:
1583
- Matching :class:`PublishedLayerSummary` objects; empty when
1584
- the server returns a non-list payload.
1585
-
1586
- Per-request options (``timeout`` / ``extra_headers``) are forwarded
1587
- to :meth:`_request`.
1574
+ """Read every page of layers published on a connection."""
1575
+ collected: list[PublishedLayerSummary] = []
1576
+ page_number = _endpoints.DEFAULT_CATALOG_PAGE
1577
+ while True:
1578
+ page = await self.list_layers_page(
1579
+ conn_id,
1580
+ LayerListQuery(page=page_number, service_name=service_name),
1581
+ timeout=timeout,
1582
+ extra_headers=extra_headers,
1583
+ )
1584
+ advanced = _endpoints._advance_catalog_page(collected, page)
1585
+ if advanced is None:
1586
+ return collected
1587
+ page_number = advanced
1588
1588
 
1589
- Raises:
1590
- HonuaHttpError: The server responded with a non-success status.
1591
- HonuaTransportError: The request failed at the transport layer.
1592
- """
1593
- connection_id = encode_path_segment(conn_id)
1594
- params: dict[str, str] | None = None
1595
- if service_name is not None:
1596
- params = {"serviceName": service_name}
1597
- data = await self._request_json(
1598
- "GET",
1599
- f"/api/v1/admin/connections/{connection_id}/layers",
1600
- params=params,
1601
- timeout=timeout,
1602
- extra_headers=extra_headers,
1589
+ async def list_layers_page(
1590
+ self,
1591
+ conn_id: str,
1592
+ query: LayerListQuery | None = None,
1593
+ *,
1594
+ timeout: float | httpx.Timeout | None = None,
1595
+ extra_headers: Mapping[str, str] | None = None,
1596
+ ) -> PublishedLayerPage:
1597
+ """Return one page of layers published on a connection."""
1598
+ selected = query or LayerListQuery()
1599
+ return PublishedLayerPage.from_dict(
1600
+ await self._request_json(
1601
+ "GET",
1602
+ f"/api/v1/admin/connections/{encode_path_segment(conn_id)}/layers",
1603
+ params=_endpoints.admin_catalog_params(
1604
+ page=selected.page,
1605
+ page_size=selected.page_size,
1606
+ name_query=selected.name_query,
1607
+ extra=(("serviceName", selected.service_name),),
1608
+ ),
1609
+ timeout=timeout,
1610
+ extra_headers=extra_headers,
1611
+ )
1603
1612
  )
1604
- if not isinstance(data, list):
1605
- return []
1606
- return [PublishedLayerSummary.from_dict(item) for item in data]
1607
1613
 
1608
1614
  async def publish_layer(
1609
1615
  self,
@@ -1736,9 +1742,7 @@ class AsyncHonuaAdminClient:
1736
1742
  return []
1737
1743
  return [PublishedLayerSummary.from_dict(item) for item in data]
1738
1744
 
1739
- # ======================================================================
1740
1745
  # Discovery
1741
- # ======================================================================
1742
1746
 
1743
1747
  async def discover_tables(
1744
1748
  self,
@@ -1771,9 +1775,7 @@ class AsyncHonuaAdminClient:
1771
1775
  )
1772
1776
  return TableDiscoveryResponse.from_dict(data)
1773
1777
 
1774
- # ======================================================================
1775
1778
  # Styles
1776
- # ======================================================================
1777
1779
 
1778
1780
  async def list_styles(
1779
1781
  self,
@@ -1997,9 +1999,7 @@ class AsyncHonuaAdminClient:
1997
1999
  )
1998
2000
  return LayerStyleResponse.from_dict(data)
1999
2001
 
2000
- # ======================================================================
2001
2002
  # Operation proposals
2002
- # ======================================================================
2003
2003
 
2004
2004
  async def list_proposals(
2005
2005
  self,
@@ -2155,9 +2155,7 @@ class AsyncHonuaAdminClient:
2155
2155
  )
2156
2156
  return OperationProposalDetail.from_dict(data)
2157
2157
 
2158
- # ======================================================================
2159
2158
  # Config
2160
- # ======================================================================
2161
2159
 
2162
2160
  async def get_config(
2163
2161
  self,
@@ -0,0 +1,102 @@
1
+ """One page of the admin service list or the published-layer list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from ._models import AdminServiceSummary, PublishedLayerSummary, _snake_keys
9
+
10
+
11
+ def _page_int(value: Any, default: int) -> int:
12
+ if value is None:
13
+ return default
14
+ return int(value)
15
+
16
+
17
+ def _require_page(data: Any, kind: str) -> dict[str, Any]:
18
+ if data is None:
19
+ return {}
20
+ if not isinstance(data, dict):
21
+ raise ValueError(f"{kind} data must be a page object.")
22
+ return _snake_keys(data)
23
+
24
+
25
+ def _page_items(raw: Any, parse: Any, kind: str) -> list[Any]:
26
+ if raw is None:
27
+ return []
28
+ if not isinstance(raw, list) or any(not isinstance(item, dict) for item in raw):
29
+ raise ValueError(f"{kind} items must be a list of objects.")
30
+ return [parse(item) for item in raw]
31
+
32
+
33
+ def _page_counts(parsed: dict[str, Any]) -> tuple[int, int, int]:
34
+ return (
35
+ _page_int(parsed.get("page"), 1),
36
+ _page_int(parsed.get("page_size"), 100),
37
+ _page_int(parsed.get("total"), 0),
38
+ )
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class ServiceListQuery:
43
+ """Selects one page of the admin service list."""
44
+
45
+ page: int = 1
46
+ page_size: int = 100
47
+ name_query: str | None = None
48
+ folder_id: str | None = None
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class ServiceListPage:
53
+ """One page of admin services and the count of every match."""
54
+
55
+ items: list[AdminServiceSummary]
56
+ page: int
57
+ page_size: int
58
+ total: int
59
+ layer_total: int
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: dict[str, Any] | None) -> ServiceListPage:
63
+ parsed = _require_page(data, "Service list")
64
+ page, page_size, total = _page_counts(parsed)
65
+ return cls(
66
+ items=_page_items(parsed.get("items"), AdminServiceSummary.from_dict, "Service list"),
67
+ page=page,
68
+ page_size=page_size,
69
+ total=total,
70
+ layer_total=_page_int(parsed.get("layer_total"), 0),
71
+ )
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class LayerListQuery:
76
+ """Selects one page of layers published on a connection."""
77
+
78
+ page: int = 1
79
+ page_size: int = 100
80
+ name_query: str | None = None
81
+ service_name: str | None = None
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class PublishedLayerPage:
86
+ """One page of published layers and the count of every match."""
87
+
88
+ items: list[PublishedLayerSummary]
89
+ page: int
90
+ page_size: int
91
+ total: int
92
+
93
+ @classmethod
94
+ def from_dict(cls, data: dict[str, Any] | None) -> PublishedLayerPage:
95
+ parsed = _require_page(data, "Layer list")
96
+ page, page_size, total = _page_counts(parsed)
97
+ return cls(
98
+ items=_page_items(parsed.get("items"), PublishedLayerSummary.from_dict, "Layer list"),
99
+ page=page,
100
+ page_size=page_size,
101
+ total=total,
102
+ )
@@ -33,6 +33,7 @@ from honua_sdk.http import (
33
33
  )
34
34
 
35
35
  from . import _endpoints
36
+ from ._catalog import LayerListQuery, PublishedLayerPage, ServiceListPage, ServiceListQuery
36
37
  from ._models import (
37
38
  DEFAULT_STYLE_ENCODING as _DEFAULT_STYLE_ENCODING,
38
39
  )
@@ -513,9 +514,7 @@ class HonuaAdminClient:
513
514
  )
514
515
  return _endpoints.unwrap_envelope(response)
515
516
 
516
- # ======================================================================
517
517
  # File imports
518
- # ======================================================================
519
518
 
520
519
  def upload_file(
521
520
  self,
@@ -569,9 +568,7 @@ class HonuaAdminClient:
569
568
  return BackgroundImportResponse.from_dict(payload)
570
569
  return ImportResult.from_dict(payload)
571
570
 
572
- # ======================================================================
573
571
  # Services
574
- # ======================================================================
575
572
 
576
573
  def list_services(
577
574
  self,
@@ -579,30 +576,41 @@ class HonuaAdminClient:
579
576
  timeout: float | httpx.Timeout | None = None,
580
577
  extra_headers: Mapping[str, str] | None = None,
581
578
  ) -> list[ServiceSummary]:
582
- """List every service registered on the admin catalog.
583
-
584
- Returns:
585
- Typed :class:`ServiceSummary` objects for each service the
586
- server reports. Empty when the server returns a non-list
587
- payload (e.g. an empty envelope).
588
-
589
- Per-request options (``timeout`` / ``extra_headers``) are forwarded
590
- to :meth:`_request`.
579
+ """Read every page of the admin service list."""
580
+ collected: list[ServiceSummary] = []
581
+ page_number = _endpoints.DEFAULT_CATALOG_PAGE
582
+ while True:
583
+ page = self.list_services_page(
584
+ ServiceListQuery(page=page_number), timeout=timeout, extra_headers=extra_headers
585
+ )
586
+ advanced = _endpoints._advance_catalog_page(collected, page)
587
+ if advanced is None:
588
+ return collected
589
+ page_number = advanced
591
590
 
592
- Raises:
593
- HonuaHttpError: The server responded with a non-success status.
594
- HonuaTransportError: The request failed before any HTTP
595
- response was received.
596
- """
597
- data = self._request_json(
598
- "GET",
599
- "/api/v1/admin/services/",
600
- timeout=timeout,
601
- extra_headers=extra_headers,
591
+ def list_services_page(
592
+ self,
593
+ query: ServiceListQuery | None = None,
594
+ *,
595
+ timeout: float | httpx.Timeout | None = None,
596
+ extra_headers: Mapping[str, str] | None = None,
597
+ ) -> ServiceListPage:
598
+ """Return one page of admin services and the count of every match."""
599
+ selected = query or ServiceListQuery()
600
+ return ServiceListPage.from_dict(
601
+ self._request_json(
602
+ "GET",
603
+ "/api/v1/admin/services/",
604
+ params=_endpoints.admin_catalog_params(
605
+ page=selected.page,
606
+ page_size=selected.page_size,
607
+ name_query=selected.name_query,
608
+ extra=(("folderId", selected.folder_id),),
609
+ ),
610
+ timeout=timeout,
611
+ extra_headers=extra_headers,
612
+ )
602
613
  )
603
- if not isinstance(data, list):
604
- return []
605
- return [ServiceSummary.from_dict(s) for s in data]
606
614
 
607
615
  def get_service_settings(
608
616
  self,
@@ -787,9 +795,7 @@ class HonuaAdminClient:
787
795
  )
788
796
  return ServiceSettingsResponse.from_dict(data)
789
797
 
790
- # ======================================================================
791
798
  # Metadata Resources
792
- # ======================================================================
793
799
 
794
800
  def list_metadata_resources(
795
801
  self,
@@ -1005,9 +1011,7 @@ class HonuaAdminClient:
1005
1011
  idempotency_key=idempotency_key,
1006
1012
  )
1007
1013
 
1008
- # ======================================================================
1009
1014
  # Manifests
1010
- # ======================================================================
1011
1015
 
1012
1016
  def get_version(
1013
1017
  self,
@@ -1199,9 +1203,7 @@ class HonuaAdminClient:
1199
1203
  )
1200
1204
  return ManifestApplyResult.from_dict(data)
1201
1205
 
1202
- # ======================================================================
1203
1206
  # Migration Toolkit
1204
- # ======================================================================
1205
1207
 
1206
1208
  def scan_migration_source(
1207
1209
  self,
@@ -1235,12 +1237,12 @@ class HonuaAdminClient:
1235
1237
  ) -> ToolboxTranslationReport:
1236
1238
  """POST /api/v1/admin/import/toolbox/translation/validate
1237
1239
 
1238
- Validate an SDK-translated ArcGIS toolbox manifest against the server's
1240
+ Validate an SDK-translated source toolbox manifest against the server's
1239
1241
  canonical geoprocessing process catalog, and return the
1240
1242
  server-authoritative per-tool classification.
1241
1243
 
1242
1244
  The server owns the round-trip proof: it never parses toolbox sources
1243
- and never emulates ``arcpy``. The SDK proposes a mapping; the catalog
1245
+ and never emulates the scripting API. The SDK proposes a mapping; the catalog
1244
1246
  decides whether that mapping is ``translated``,
1245
1247
  ``partially-translated``, or ``unsupported``, and returns the specific
1246
1248
  reasons a tool cannot be fully translated.
@@ -1271,9 +1273,7 @@ class HonuaAdminClient:
1271
1273
  )
1272
1274
  return ToolboxTranslationReport.from_dict(data)
1273
1275
 
1274
- # ======================================================================
1275
1276
  # Connections
1276
- # ======================================================================
1277
1277
 
1278
1278
  def list_connections(
1279
1279
  self,
@@ -1562,9 +1562,7 @@ class HonuaAdminClient:
1562
1562
  )
1563
1563
  return KeyRotationResult.from_dict(data)
1564
1564
 
1565
- # ======================================================================
1566
1565
  # Layers
1567
- # ======================================================================
1568
1566
 
1569
1567
  def list_layers(
1570
1568
  self,
@@ -1574,37 +1572,45 @@ class HonuaAdminClient:
1574
1572
  timeout: float | httpx.Timeout | None = None,
1575
1573
  extra_headers: Mapping[str, str] | None = None,
1576
1574
  ) -> list[PublishedLayerSummary]:
1577
- """List published layers belonging to a connection.
1578
-
1579
- Args:
1580
- conn_id: Connection identifier; URL-encoded.
1581
- service_name: When provided, restrict to one service's layers.
1582
-
1583
- Returns:
1584
- Matching :class:`PublishedLayerSummary` objects; empty when
1585
- the server returns a non-list payload.
1586
-
1587
- Per-request options (``timeout`` / ``extra_headers``) are forwarded
1588
- to :meth:`_request`.
1575
+ """Read every page of layers published on a connection."""
1576
+ collected: list[PublishedLayerSummary] = []
1577
+ page_number = _endpoints.DEFAULT_CATALOG_PAGE
1578
+ while True:
1579
+ page = self.list_layers_page(
1580
+ conn_id,
1581
+ LayerListQuery(page=page_number, service_name=service_name),
1582
+ timeout=timeout,
1583
+ extra_headers=extra_headers,
1584
+ )
1585
+ advanced = _endpoints._advance_catalog_page(collected, page)
1586
+ if advanced is None:
1587
+ return collected
1588
+ page_number = advanced
1589
1589
 
1590
- Raises:
1591
- HonuaHttpError: The server responded with a non-success status.
1592
- HonuaTransportError: The request failed at the transport layer.
1593
- """
1594
- connection_id = encode_path_segment(conn_id)
1595
- params: dict[str, str] | None = None
1596
- if service_name is not None:
1597
- params = {"serviceName": service_name}
1598
- data = self._request_json(
1599
- "GET",
1600
- f"/api/v1/admin/connections/{connection_id}/layers",
1601
- params=params,
1602
- timeout=timeout,
1603
- extra_headers=extra_headers,
1590
+ def list_layers_page(
1591
+ self,
1592
+ conn_id: str,
1593
+ query: LayerListQuery | None = None,
1594
+ *,
1595
+ timeout: float | httpx.Timeout | None = None,
1596
+ extra_headers: Mapping[str, str] | None = None,
1597
+ ) -> PublishedLayerPage:
1598
+ """Return one page of layers published on a connection."""
1599
+ selected = query or LayerListQuery()
1600
+ return PublishedLayerPage.from_dict(
1601
+ self._request_json(
1602
+ "GET",
1603
+ f"/api/v1/admin/connections/{encode_path_segment(conn_id)}/layers",
1604
+ params=_endpoints.admin_catalog_params(
1605
+ page=selected.page,
1606
+ page_size=selected.page_size,
1607
+ name_query=selected.name_query,
1608
+ extra=(("serviceName", selected.service_name),),
1609
+ ),
1610
+ timeout=timeout,
1611
+ extra_headers=extra_headers,
1612
+ )
1604
1613
  )
1605
- if not isinstance(data, list):
1606
- return []
1607
- return [PublishedLayerSummary.from_dict(item) for item in data]
1608
1614
 
1609
1615
  def publish_layer(
1610
1616
  self,
@@ -1737,9 +1743,7 @@ class HonuaAdminClient:
1737
1743
  return []
1738
1744
  return [PublishedLayerSummary.from_dict(item) for item in data]
1739
1745
 
1740
- # ======================================================================
1741
1746
  # Discovery
1742
- # ======================================================================
1743
1747
 
1744
1748
  def discover_tables(
1745
1749
  self,
@@ -1772,9 +1776,7 @@ class HonuaAdminClient:
1772
1776
  )
1773
1777
  return TableDiscoveryResponse.from_dict(data)
1774
1778
 
1775
- # ======================================================================
1776
1779
  # Styles
1777
- # ======================================================================
1778
1780
 
1779
1781
  def list_styles(
1780
1782
  self,
@@ -1998,9 +2000,7 @@ class HonuaAdminClient:
1998
2000
  )
1999
2001
  return LayerStyleResponse.from_dict(data)
2000
2002
 
2001
- # ======================================================================
2002
2003
  # Operation proposals
2003
- # ======================================================================
2004
2004
 
2005
2005
  def list_proposals(
2006
2006
  self,
@@ -2156,9 +2156,7 @@ class HonuaAdminClient:
2156
2156
  )
2157
2157
  return OperationProposalDetail.from_dict(data)
2158
2158
 
2159
- # ======================================================================
2160
2159
  # Config
2161
- # ======================================================================
2162
2160
 
2163
2161
  def get_config(
2164
2162
  self,
@@ -0,0 +1,119 @@
1
+ """Pure response-parse and header helpers shared by the admin clients.
2
+
3
+ The admin sync (:class:`HonuaAdminClient`) and async
4
+ (:class:`AsyncHonuaAdminClient`) clients duplicate the
5
+ ``ApiResponse``-unwrap logic and the ``Idempotency-Key`` header policy
6
+ character-for-character. Both modules pull those pieces from this
7
+ module so the only differences left are the unavoidable ``await``
8
+ keywords and the sync/async ``def`` declarations.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping, Sequence
14
+ from typing import Any, Protocol, TypeVar
15
+
16
+ import httpx
17
+ from honua_sdk.http import build_idempotency_headers
18
+
19
+ IMPORT_UPLOAD_PATH = "/api/v1/admin/import/upload"
20
+ SERVICE_ACCESS_POLICY_PATH = "/api/v1/admin/services/{name}/access-policy"
21
+
22
+
23
+ DEFAULT_CATALOG_PAGE = 1
24
+ DEFAULT_CATALOG_PAGE_SIZE = 100
25
+ MAX_CATALOG_PAGE_SIZE = 100
26
+ MAX_CATALOG_NAME_QUERY_LENGTH = 200
27
+
28
+
29
+ def admin_catalog_params(
30
+ *,
31
+ page: int = DEFAULT_CATALOG_PAGE,
32
+ page_size: int = DEFAULT_CATALOG_PAGE_SIZE,
33
+ name_query: str | None = None,
34
+ extra: tuple[tuple[str, str | None], ...] = (),
35
+ ) -> dict[str, str]:
36
+ """Query string for one page of an admin catalog list."""
37
+ if page < 1:
38
+ raise ValueError("page must be at least 1.")
39
+ if page_size < 1 or page_size > MAX_CATALOG_PAGE_SIZE:
40
+ raise ValueError("page_size must be from 1 to 100.")
41
+ name = _blank_to_none(name_query)
42
+ if name is not None and len(name) > MAX_CATALOG_NAME_QUERY_LENGTH:
43
+ raise ValueError("name query is longer than 200 characters.")
44
+ params = {"page": str(page), "pageSize": str(page_size)}
45
+ if name is not None:
46
+ params["q"] = name
47
+ for key, value in extra:
48
+ trimmed = _blank_to_none(value)
49
+ if trimmed is not None:
50
+ params[key] = trimmed
51
+ return params
52
+
53
+
54
+ def is_last_catalog_page(item_count: int, page_size: int, total: int, collected: int) -> bool:
55
+ """A short page, an empty page, or a full total ends the walk."""
56
+ return item_count == 0 or collected >= total or item_count < page_size
57
+
58
+
59
+ _ItemT_co = TypeVar("_ItemT_co", covariant=True)
60
+
61
+
62
+ class _CatalogPage(Protocol[_ItemT_co]):
63
+ @property
64
+ def items(self) -> Sequence[_ItemT_co]: ...
65
+
66
+ @property
67
+ def page(self) -> int: ...
68
+
69
+ @property
70
+ def page_size(self) -> int: ...
71
+
72
+ @property
73
+ def total(self) -> int: ...
74
+
75
+
76
+ def _advance_catalog_page(collected: list[_ItemT_co], page: _CatalogPage[_ItemT_co]) -> int | None:
77
+ """Append this page and return the next page number, or None when the walk is done."""
78
+ collected.extend(page.items)
79
+ if is_last_catalog_page(len(page.items), page.page_size, page.total, len(collected)):
80
+ return None
81
+ return page.page + 1
82
+
83
+
84
+ def _blank_to_none(value: str | None) -> str | None:
85
+ if value is None:
86
+ return None
87
+ trimmed = value.strip()
88
+ return trimmed or None
89
+
90
+
91
+ def unwrap_envelope(response: httpx.Response) -> Any:
92
+ """Strip the ``{"success": true, "data": ...}`` envelope, if present.
93
+
94
+ * Empty bodies return ``None``.
95
+ * Bodies that are not JSON return ``response.text`` as a fallback.
96
+ * JSON objects with a ``data`` key return the inner value.
97
+ * Anything else returns the parsed payload verbatim.
98
+ """
99
+ if not response.content:
100
+ return None
101
+ try:
102
+ payload = response.json()
103
+ except ValueError:
104
+ return response.text
105
+ if isinstance(payload, Mapping) and "data" in payload:
106
+ return payload["data"]
107
+ return payload
108
+
109
+
110
+ __all__ = [
111
+ "DEFAULT_CATALOG_PAGE",
112
+ "DEFAULT_CATALOG_PAGE_SIZE",
113
+ "MAX_CATALOG_NAME_QUERY_LENGTH",
114
+ "MAX_CATALOG_PAGE_SIZE",
115
+ "admin_catalog_params",
116
+ "build_idempotency_headers",
117
+ "is_last_catalog_page",
118
+ "unwrap_envelope",
119
+ ]
@@ -14,7 +14,7 @@ from typing import Any, Literal, TypeAlias
14
14
  # release line. Honua Server versions are ``<year>.<release>.<patch>`` SemVer triples
15
15
  # (2026.1.1); the major tracks the year, not compatibility, so the control-plane API
16
16
  # major carries the breaking-change signal. Builds that predate the server's version
17
- # identity (honua-server#4693) report ``1.0.0+<sha>`` and fall below the floor.
17
+ # identity report ``1.0.0+<sha>`` and fall below the floor.
18
18
  # tests/admin/test_server_floor_baseline.py fails when this and the baseline disagree;
19
19
  # the JS and .NET SDKs carry the same value.
20
20
  MINIMUM_SUPPORTED_SERVER_VERSION = "2026.1.0-0"
@@ -102,9 +102,7 @@ def _parse_version_components(version: str | None) -> tuple[int, int, int] | Non
102
102
  return (parts[0], parts[1], parts[2])
103
103
 
104
104
 
105
- # ---------------------------------------------------------------------------
106
105
  # Response models (frozen)
107
- # ---------------------------------------------------------------------------
108
106
 
109
107
 
110
108
  @dataclass(frozen=True, slots=True)
@@ -845,9 +843,7 @@ class LayerStyleResponse:
845
843
  return cls(**_extract_fields(cls, d))
846
844
 
847
845
 
848
- # ---------------------------------------------------------------------------
849
846
  # OGC API - Styles (styleId-keyed; ADR-0048)
850
- # ---------------------------------------------------------------------------
851
847
 
852
848
  # Stylesheet encoding identifiers accepted by the styleId-keyed client. These
853
849
  # map to the ``Accept`` media types the OGC API - Styles surface negotiates on
@@ -977,9 +973,7 @@ class OgcStylesheet:
977
973
  return parsed
978
974
 
979
975
 
980
- # ---------------------------------------------------------------------------
981
976
  # Request models (mutable)
982
- # ---------------------------------------------------------------------------
983
977
 
984
978
 
985
979
  @dataclass
@@ -1081,9 +1075,7 @@ class LayerStyleUpdateRequest:
1081
1075
  return d
1082
1076
 
1083
1077
 
1084
- # ---------------------------------------------------------------------------
1085
1078
  # Migration toolkit artifact models
1086
- # ---------------------------------------------------------------------------
1087
1079
 
1088
1080
 
1089
1081
  @dataclass(frozen=True, slots=True)
@@ -1881,9 +1873,7 @@ class ToolboxTranslationReport:
1881
1873
  return {**identity, **d}
1882
1874
 
1883
1875
 
1884
- # ---------------------------------------------------------------------------
1885
1876
  # Request models (mutable)
1886
- # ---------------------------------------------------------------------------
1887
1877
 
1888
1878
 
1889
1879
  @dataclass
@@ -1920,7 +1910,7 @@ class ToolboxToolDescriptor:
1920
1910
 
1921
1911
  @dataclass
1922
1912
  class ToolboxTranslationManifest:
1923
- """Client-proposed translation manifest for one ArcGIS toolbox.
1913
+ """Client-proposed translation manifest for one source toolbox.
1924
1914
 
1925
1915
  ``source_format`` is the source toolbox format: ``pyt``, ``atbx``, or
1926
1916
  ``tbx``. Redact local paths and credentials out of ``source_label`` before
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "honua-admin"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Admin / control-plane client for Honua geospatial server"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -27,7 +27,7 @@ classifiers = [
27
27
  dependencies = [
28
28
  # The floor tracks the honua-sdk release (release-please generic updater on the line
29
29
  # below) so honua-admin always requires the SDK it was released with.
30
- "honua-sdk>=0.2.0,<0.3", # x-release-please-version
30
+ "honua-sdk>=0.2.2,<0.3", # x-release-please-version
31
31
  ]
32
32
 
33
33
  [project.scripts]
@@ -1,45 +0,0 @@
1
- """Pure response-parse and header helpers shared by the admin clients.
2
-
3
- The admin sync (:class:`HonuaAdminClient`) and async
4
- (:class:`AsyncHonuaAdminClient`) clients duplicate the
5
- ``ApiResponse``-unwrap logic and the ``Idempotency-Key`` header policy
6
- character-for-character. Both modules pull those pieces from this
7
- module so the only differences left are the unavoidable ``await``
8
- keywords and the sync/async ``def`` declarations.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- from collections.abc import Mapping
14
- from typing import Any
15
-
16
- import httpx
17
- from honua_sdk.http import build_idempotency_headers
18
-
19
- IMPORT_UPLOAD_PATH = "/api/v1/admin/import/upload"
20
- SERVICE_ACCESS_POLICY_PATH = "/api/v1/admin/services/{name}/access-policy"
21
-
22
-
23
- def unwrap_envelope(response: httpx.Response) -> Any:
24
- """Strip the ``{"success": true, "data": ...}`` envelope, if present.
25
-
26
- * Empty bodies return ``None``.
27
- * Bodies that are not JSON return ``response.text`` as a fallback.
28
- * JSON objects with a ``data`` key return the inner value.
29
- * Anything else returns the parsed payload verbatim.
30
- """
31
- if not response.content:
32
- return None
33
- try:
34
- payload = response.json()
35
- except ValueError:
36
- return response.text
37
- if isinstance(payload, Mapping) and "data" in payload:
38
- return payload["data"]
39
- return payload
40
-
41
-
42
- __all__ = [
43
- "build_idempotency_headers",
44
- "unwrap_envelope",
45
- ]
File without changes
File without changes