diff --git a/.env.template b/.env.template
new file mode 100644
index 0000000..6dd3b0e
--- /dev/null
+++ b/.env.template
@@ -0,0 +1,4 @@
+WORKSPACEFOLDER="${YourWorkSpaceHere}"
+PYTHONPATH="${WORKSPACEFOLDER}/src"
+KOOPCACHE_DIR="${WORKSPACEFOLDER}/.koopcache"
+KOORDINATES_API_KEY="**"
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index c06d503..eb8b4b2 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -2,11 +2,11 @@
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.pytestArgs": [
- "tests/"
+ "tests"
],
"python.defaultInterpreterPath": "./.venv/Scripts/python.exe",
"python.terminal.activateEnvironment": true,
"pythonTestExplorer.testFramework": "pytest",
"pythonTestExplorer.testplanEnabled": false,
"testExplorer.useNativeTesting": true
-}
+}
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index e853e02..1cdad93 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,7 +12,7 @@ requires = [
]
[project]
-name = "whitelabel"
+name = "koop"
description = "A minimal Python package template with a simple Hello World example."
readme = "README.md"
license = "MIT"
@@ -33,7 +33,11 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dynamic = [ "urls", "version" ]
-dependencies = [ ]
+dependencies = [
+ "defusedxml>=0.7.1",
+ "pydantic>=2.0.0",
+ "requests>=2.0.0",
+]
[dependency-groups]
dev = [
@@ -74,7 +78,7 @@ source = "vcs"
"Source Archive" = "https://github.com/tonkintaylor/YOUR_REPOSITORY/archive/{commit_hash}.zip"
[tool.hatch.build.hooks.vcs]
-version-file = "src/whitelabel/_version.py"
+version-file = "src/koop/_version.py"
[tool.ruff]
line-length = 88
@@ -229,5 +233,5 @@ output = "test-reports/coverage.xml"
[tool.uv]
default-groups = [ "dev", "test", "doc" ]
-required-version = "==0.8.3" # sync with .pre-commit-config.yaml and release.yml
+required-version = ">=0.7.13" # sync with .pre-commit-config.yaml and release.yml
link-mode = "symlink"
diff --git a/src/koop/__init__.py b/src/koop/__init__.py
new file mode 100644
index 0000000..aad894a
--- /dev/null
+++ b/src/koop/__init__.py
@@ -0,0 +1,32 @@
+from pathlib import Path
+
+from koop.backend.conn import KoordinatesConnection
+from koop.get_latest import get_latest_layer
+
+__all__ = ["get_layer_from_id"]
+
+
+def get_layer_from_id(
+ layer_id: int, api_key: str, domain: str = "ttgroup.koordinates.com"
+) -> Path:
+ """Helper function to get the latest layer from Koordinates.
+
+ Args:
+ layer_id (int): The layer ID.
+ api_key (str): Your API key.
+ domain (str, optional): The domain of the API.
+
+ Returns:
+ Path: The path to the latest layer.
+
+ """
+ conn = KoordinatesConnection(
+ api_key,
+ domain,
+ )
+
+ try:
+ return get_latest_layer(conn=conn, layer_id=layer_id)
+ finally:
+ if conn:
+ conn.close()
diff --git a/src/koop/backend/__init__.py b/src/koop/backend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/koop/backend/api/__init__.py b/src/koop/backend/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/koop/backend/api/download_layer.py b/src/koop/backend/api/download_layer.py
new file mode 100644
index 0000000..f6e3d14
--- /dev/null
+++ b/src/koop/backend/api/download_layer.py
@@ -0,0 +1,136 @@
+from pathlib import Path
+from time import sleep
+from zipfile import ZipFile
+
+from requests import Session
+
+from koop.backend.api.exports.create_export import create_export
+from koop.backend.api.exports.get_exports import get_export_from_id
+from koop.backend.api.exports.validate_export import validate_export
+from koop.backend.api.layers_and_tables.get_details import get_layer_details
+
+
+def download_layer(
+ *,
+ session: Session,
+ domain: str,
+ api_version: str,
+ layer_id: int,
+ output_dir: Path,
+) -> Path:
+ """Download a layer from the Koordinates server.
+
+ Args:
+ session: The session to use to make the request.
+ domain: The domain to use for the request.
+ api_version: The version of the API to use for the request.
+ layer_id: The ID of the layer to download.
+ output_dir: The directory to save the downloaded layer to.
+
+ Returns:
+ The path to the directory whether the layer is saved.
+
+ """
+ layer_details = get_layer_details(session, domain, api_version, layer_id)
+
+ # validate
+ validation_response = validate_export(
+ session,
+ domain,
+ api_version,
+ layer_id,
+ export_format=layer_details.kind.value,
+ )
+
+ if not validation_response.is_valid:
+ raise ValueError(validation_response.invalid_reasons)
+
+ export = create_export(
+ session,
+ domain,
+ api_version,
+ layer_id,
+ export_format=layer_details.kind.value,
+ )
+
+ # wait for export to complete
+ export_url = wait_for_export(session, domain, api_version, export.id)
+
+ # download
+ filename = download_export(session, export_url, output_dir)
+
+ # unzip
+ unzipped_dir = unzip_export(output_dir / filename)
+
+ return unzipped_dir
+
+
+def wait_for_export(
+ session: Session, domain: str, api_version: str, export_id: int
+) -> str:
+ """Wait for the export to complete.
+
+ Args:
+ session: The session to use to make the request.
+ domain: The domain to use for the request.
+ api_version: The version of the API to use for the request.
+ export_id: The ID of the export to wait for.
+
+ """
+ state = "processing"
+ while state != "complete":
+ sleep(1)
+
+ export = get_export_from_id(session, domain, api_version, export_id)
+ state = export.state
+
+ if state not in ["processing", "complete"]:
+ msg = f"Export failed with state: {state}"
+ raise ValueError(msg)
+
+ return export.download_url
+
+
+def download_export(session: Session, export_url: str, output_dir: Path) -> Path:
+ """Download the export from the Koordinates server.
+
+ Args:
+ session: The session to use to make the request.
+ domain: The domain to use for the request.
+ api_version: The version of the API to use for the request.
+ export_url: The URL of the export to download.
+ output_dir: The directory to save the downloaded export to.
+
+ """
+ with session.get(export_url, stream=True) as response:
+ response.raise_for_status()
+
+ if "Content-Disposition" in response.headers:
+ filename = response.headers["Content-Disposition"].split(
+ "filename*=UTF-8''"
+ )[1]
+ else:
+ filename = "export.zip"
+
+ with (output_dir / filename).open("wb") as file:
+ for chunk in response.iter_content(chunk_size=1024):
+ file.write(chunk)
+
+ return filename
+
+
+def unzip_export(zip_path: Path) -> Path:
+ """Unzip the export.
+
+ Args:
+ zip_path: The path to the export to unzip.
+
+ """
+ output_dir = zip_path.parent / zip_path.stem
+
+ with ZipFile(zip_path, "r") as zip_ref:
+ zip_ref.extractall(output_dir)
+
+ zip_path.unlink()
+
+ return output_dir
diff --git a/src/koop/backend/api/exports/__init__.py b/src/koop/backend/api/exports/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/koop/backend/api/exports/create_export.py b/src/koop/backend/api/exports/create_export.py
new file mode 100644
index 0000000..6cb7baa
--- /dev/null
+++ b/src/koop/backend/api/exports/create_export.py
@@ -0,0 +1,64 @@
+from pydantic import BaseModel
+from requests import Session
+
+from koop.backend.api.exports.post_export import post_export
+
+
+def create_export( # noqa: PLR0913
+ session: Session,
+ domain: str,
+ api_version: str,
+ layer_id: int,
+ export_format: str,
+ file_type: str | None = None,
+ tiles: list[str] | None = None,
+) -> dict:
+ """Create and start a new export.
+
+ https://apidocs.koordinates.com/#tag/Exports/operation/postExport
+
+ Args:
+ session: The session to use to make the request.
+ domain: The domain to use to make the request.
+ api_version: The version of the API to use.
+ layer_id: The ID of the layer to export.
+ export_format: The format to export the layer to.
+ file_type: The file type to export the layer to. Defaults to None.
+ tiles: The tiles to export. Defaults to None.
+
+ Returns:
+ dict: The JSON response.
+ """
+ url = f"https://{domain}/services/api/v{api_version}/exports/"
+
+ response = post_export(
+ session, url, domain, api_version, layer_id, export_format, file_type, tiles
+ )
+
+ return _ResponseSchema(**response.json())
+
+
+class _ResponseSchema(BaseModel):
+ """The response schema."""
+
+ id: int
+ name: str
+ created_at: str | None
+ created_via: str
+ state: str
+ url: str
+ download_url: str | None
+ user: dict
+ delivery: dict
+ items: list[dict]
+ crs: dict
+ extent: str | None
+ formats: dict
+ options: dict | None
+ size_estimate_unzipped: int
+ size_complete_zipped: int | None
+ size_complete_unzipped: int | None
+ is_cropped: bool
+ invoice: str | None
+ _from: dict
+ progress: float
diff --git a/src/koop/backend/api/exports/enums.py b/src/koop/backend/api/exports/enums.py
new file mode 100644
index 0000000..6db4b97
--- /dev/null
+++ b/src/koop/backend/api/exports/enums.py
@@ -0,0 +1,21 @@
+from enum import Enum
+from typing import ClassVar
+
+
+class GridExportFormats(Enum):
+ """Grid Export Formats enumeration."""
+
+ geotiff = "image/tiff;subtype=geotiff"
+
+
+class VectorExportFormats(Enum):
+ """Vector Export Formats enumeration."""
+
+ geopackage = "application/x-ogc-gpkg"
+
+
+class ExportFormats(dict, Enum):
+ """Represents the export formats for raster and vector data."""
+
+ grid: ClassVar = {"grid": GridExportFormats.geotiff.value}
+ vector: ClassVar = {"vector": VectorExportFormats.geopackage.value}
diff --git a/src/koop/backend/api/exports/get_exports.py b/src/koop/backend/api/exports/get_exports.py
new file mode 100644
index 0000000..6d4adbb
--- /dev/null
+++ b/src/koop/backend/api/exports/get_exports.py
@@ -0,0 +1,56 @@
+from datetime import datetime
+
+from pydantic import BaseModel, TypeAdapter
+from requests import Session
+
+
+class _ResponseSchema(BaseModel):
+ """The export schema."""
+
+ id: int
+ name: str
+ created_at: datetime
+ state: str
+ url: str
+ download_url: str | None
+
+
+def get_exports(session: Session, domain: str, api_version: str) -> list:
+ """Returns a list of exports you've created.
+
+ https://apidocs.koordinates.com/#tag/Exports/operation/getExports
+
+ Args:
+ session (requests.Session): The session to use to make the request.
+ domain (str): The domain to use to make the request.
+ api_version (str): The version of the API to use.
+
+ Returns:
+ list: The JSON response.
+ """
+ url = f"https://{domain}/services/api/v{api_version}/exports/"
+
+ with session.get(url) as response:
+ response.raise_for_status()
+ return TypeAdapter(list[_ResponseSchema]).validate_python(response.json())
+
+
+def get_export_from_id(
+ session: Session, domain: str, api_version: str, export_id: int
+) -> _ResponseSchema:
+ """Returns a list of exports you've created.
+
+ Args:
+ session (requests.Session): The session to use to make the request.
+ domain (str): The domain to use to make the request.
+ api_version (str): The version of the API to use.
+ export_id (int): The ID of the export to get.
+
+ Returns:
+ list: The JSON response.
+ """
+ url = f"https://{domain}/services/api/v{api_version}/exports/{export_id}/"
+
+ with session.get(url) as response:
+ response.raise_for_status()
+ return _ResponseSchema(**response.json())
diff --git a/src/koop/backend/api/exports/post_export.py b/src/koop/backend/api/exports/post_export.py
new file mode 100644
index 0000000..d7df380
--- /dev/null
+++ b/src/koop/backend/api/exports/post_export.py
@@ -0,0 +1,57 @@
+from requests import Response, Session
+
+from koop.backend.api.exports.enums import (
+ ExportFormats,
+)
+from koop.backend.api.exports.schemas import ExportRequestSchema
+
+
+def post_export( # noqa: PLR0913
+ session: Session,
+ url: str,
+ domain: str,
+ api_version: str,
+ layer_id: int,
+ export_format: str,
+ file_type: str | None = None,
+ tiles: list[str] | None = None,
+) -> Response:
+ """Post an export request.
+
+ Args:
+ session: The session to use to make the request.
+ url: The URL to use to make the request.
+ domain: The domain to use to make the request.
+ api_version: The version of the API to use.
+ layer_id: The ID of the layer to export.
+ export_format: The format to export the layer to.
+ file_type: The file type to export the layer to. Defaults to None.
+ tiles: The tiles to export. Defaults to None.
+
+ Returns:
+ requests.Response: The response.
+ """
+ data = {
+ "formats": _layer_format(export_format, file_type),
+ "items": [
+ {
+ "item": f"https://{domain}/services/api/v{api_version}/layers/{layer_id}/",
+ }
+ ],
+ }
+
+ if tiles:
+ data["items"][0]["tiles"] = tiles
+
+ data = ExportRequestSchema(**data)
+
+ with session.post(url, json=data.model_dump(exclude_none=True)) as response:
+ response.raise_for_status()
+ return response
+
+
+def _layer_format(export_format: str, file_type: str | None = None) -> dict:
+ if file_type is None:
+ return ExportFormats[export_format].value
+ msg = f"Invalid export format: {file_type}"
+ raise ValueError(msg)
diff --git a/src/koop/backend/api/exports/schemas.py b/src/koop/backend/api/exports/schemas.py
new file mode 100644
index 0000000..ade53e0
--- /dev/null
+++ b/src/koop/backend/api/exports/schemas.py
@@ -0,0 +1,47 @@
+from typing import Literal
+
+from pydantic import BaseModel
+
+from koop.backend.api.exports.enums import (
+ ExportFormats,
+)
+from koop.backend.crs import NZTM2000_EPSG
+
+
+class PolygonExtent(BaseModel):
+ """https://geojson.org/geojson-spec.html#polygon."""
+
+ type: Literal["Polygon"]
+ coordinates: list[list[list[float]]]
+
+
+class MultiPolygonExtent(BaseModel):
+ """https://geojson.org/geojson-spec.html#multipolygon."""
+
+ type: Literal["MultiPolygon"]
+ coordinates: list[list[list[list[float]]]]
+
+
+class ExportRequestItems(BaseModel):
+ """The request items schema."""
+
+ item: str
+ color: str | None = None
+ tiles: list[str] | None = None
+ raster_resolution_multiplier: int | None = None
+ include_raster_tab: bool | None = None
+ include_geometry_field: bool | None = None
+ include_hatching: bool | None = None
+ include_xdata: bool | None = None
+
+
+class ExportRequestSchema(BaseModel):
+ """The request schema."""
+
+ crs: str = NZTM2000_EPSG
+ formats: ExportFormats
+ items: list[ExportRequestItems]
+ name: str | None = None
+ extent: None = None
+ options: dict | None = None
+ delivery: dict[str, str] = {"method": "download"}
diff --git a/src/koop/backend/api/exports/validate_export.py b/src/koop/backend/api/exports/validate_export.py
new file mode 100644
index 0000000..4f9738b
--- /dev/null
+++ b/src/koop/backend/api/exports/validate_export.py
@@ -0,0 +1,64 @@
+from pydantic import BaseModel
+from requests import Session
+
+from koop.backend.api.exports.post_export import post_export
+
+
+class _ResponseItemsSchema(BaseModel):
+ item: str
+ color: str | None = None
+ license: dict | None = None
+ price: float
+ currency: str | None
+ is_valid: bool
+ invalid_reasons: list[str]
+ size_estimate_zipped: int
+
+
+class _ResponseSchema(BaseModel):
+ items: list[_ResponseItemsSchema]
+ size_estimate_zipped: int
+ is_valid: bool
+ invalid_reasons: list[str]
+ suggested_crs: list[dict]
+ suggested_crs_vertical: list[dict]
+ name: str
+ zip_filename: str
+ delivery: dict
+ courier_cost: dict
+
+
+def validate_export( # noqa: PLR0913
+ session: Session,
+ domain: str,
+ api_version: str,
+ layer_id: int,
+ export_format: str,
+ file_type: str | None = None,
+ tiles: list[str] | None = None,
+) -> _ResponseSchema:
+ """Validate an export request.
+
+ https://apidocs.koordinates.com/#tag/Exports/operation/validateExportRequest
+ The Export Validation endpoint accepts the same content as the Export Creation
+ API but will not create/start the export and will return additional information.
+
+ Args:
+ session: The session to use to make the request.
+ domain: The domain to use to make the request.
+ api_version: The version of the API to use.
+ layer_id: The ID of the layer to export.
+ export_format: The format to export the layer to.
+ file_type: The file type to export the layer to. Defaults to None.
+ tiles: The tiles to export. Defaults to None.
+
+ Returns:
+ dict: The JSON response.
+ """
+ url = f"https://{domain}/services/api/v{api_version}/exports/validate/"
+
+ response = post_export(
+ session, url, domain, api_version, layer_id, export_format, file_type, tiles
+ )
+
+ return _ResponseSchema(**response.json())
diff --git a/src/koop/backend/api/layers_and_tables/__init__.py b/src/koop/backend/api/layers_and_tables/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/koop/backend/api/layers_and_tables/get_details.py b/src/koop/backend/api/layers_and_tables/get_details.py
new file mode 100644
index 0000000..3dea618
--- /dev/null
+++ b/src/koop/backend/api/layers_and_tables/get_details.py
@@ -0,0 +1,92 @@
+from enum import Enum
+from hashlib import md5
+
+from pydantic import BaseModel, ConfigDict
+from requests import Session
+
+
+class _LayerTypes(str, Enum):
+ """The layer types."""
+
+ vector = "vector"
+ grid = "grid"
+
+
+class LayerDetails(BaseModel):
+ """The Layer Details schema."""
+
+ model_config = ConfigDict(frozen=True)
+
+ id: int
+ url: str
+ type: str
+ title: str
+ kind: _LayerTypes
+ published_at: str
+ metadata: dict | None
+ version_id: int
+
+ def get_hex_hash(self) -> str:
+ """Generate a hexadecimal hash for the layer details."""
+ hash_key = (
+ self.id,
+ self.url,
+ self.type,
+ self.title,
+ self.kind,
+ self.published_at,
+ )
+ hash_key = str(hash_key).encode("utf-8")
+ hash_digest = md5(hash_key).hexdigest() # noqa: S324, md5 not used for security
+ return hash_digest
+
+
+def get_layer_details(
+ session: Session,
+ domain: str,
+ api_version: str,
+ layer_id: int,
+ version_id: int | None = None,
+) -> LayerDetails:
+ """Get details of a layer or a table.
+
+ https://apidocs.koordinates.com/#tag/Layers/operation/getLayersById
+
+ Args:
+ session: The session to use to make the request.
+ domain: The domain to use to make the request.
+ api_version: The version of the API to use.
+ layer_id: The ID of the export to get.
+ version_id: The version of the layer to get, if not provided the latest version
+ will be returned.
+
+ Returns:
+ list: The JSON response.
+ """
+ url = f"https://{domain}/services/api/v{api_version}/layers/{layer_id}/"
+
+ if version_id is not None:
+ url += f"versions/{version_id}/"
+
+ with session.get(url) as response:
+ response.raise_for_status()
+ response_dict = response.json()
+ return LayerDetails(**response_dict, version_id=response_dict["version"]["id"])
+
+
+def get_layer_metadata(session: Session, domain: str, api_version: str, layer_id: int) -> str:
+ """Get the metadata for a layer."""
+
+ layer_details = get_layer_details(
+ session=session, domain=domain, api_version=api_version, layer_id=layer_id
+ )
+ if layer_details.metadata is None:
+ msg = "Layer has no metadata."
+ raise ValueError(msg)
+
+ dc_url = layer_details.metadata["dc"]
+
+ with session.get(dc_url) as response:
+ raw_xml = response.content
+
+ return raw_xml
diff --git a/src/koop/backend/conn.py b/src/koop/backend/conn.py
new file mode 100644
index 0000000..f132d27
--- /dev/null
+++ b/src/koop/backend/conn.py
@@ -0,0 +1,61 @@
+# https://apidocs.koordinates.com/
+
+import os
+
+import requests
+
+
+class KoordinatesConnection:
+ """A class representing the Koordinates API."""
+
+ def __init__(
+ self,
+ api_key: str | None = None,
+ domain: str = "ttgroup.koordinates.com",
+ api_version: str = "1.x",
+ ) -> None:
+ """Initialize the Koordinates API client.
+
+ Args:
+ api_key: The API key. If not provided, it will be read from the environment.
+ domain: The domain of the API. Defaults to "ttgroup.koordinates.com". Use
+ "data.linz.govt.nz" for querying from LINZ.
+ api_version: The version of the API. Defaults to "1.x".
+ """
+ if api_key is None:
+ # Get it from the environment
+ api_key = os.environ.get("KOORDINATES_API_KEY")
+
+ if api_key is None:
+ msg = "Please set the KOORDINATES_API_KEY environment variable in the .env file."
+ raise ValueError(msg)
+
+ self.domain = domain
+ self.api_key = api_key
+ self.api_version = api_version
+ self.session = self._create_session()
+
+ def _create_session(self) -> requests.Session:
+ """Create a session with the Koordinates API.
+
+ Returns:
+ requests.Session: The session.
+ """
+ session = requests.Session()
+
+ # HeaderTokenAuthentication
+ session.headers.update(
+ {
+ "Authorization": f"key {self.api_key}",
+ }
+ )
+
+ # Authenticate the session
+ response = session.get(f"https://{self.domain}/login/?next=/")
+ response.raise_for_status()
+
+ return session
+
+ def close(self) -> None:
+ """Close the session."""
+ self.session.close()
diff --git a/src/koop/backend/crs.py b/src/koop/backend/crs.py
new file mode 100644
index 0000000..9f406c9
--- /dev/null
+++ b/src/koop/backend/crs.py
@@ -0,0 +1,2 @@
+NZTM2000_EPSG = "EPSG:2193"
+WGS84_EPSG = "EPSG:4326"
diff --git a/src/koop/get_latest.py b/src/koop/get_latest.py
new file mode 100644
index 0000000..d22f353
--- /dev/null
+++ b/src/koop/get_latest.py
@@ -0,0 +1,90 @@
+import os
+import shutil
+from pathlib import Path
+
+from koop.backend.api.download_layer import download_layer
+from koop.backend.api.layers_and_tables.get_details import get_layer_details
+from koop.backend.conn import KoordinatesConnection
+
+
+def get_latest_layer(
+ *,
+ conn: KoordinatesConnection,
+ layer_id: int,
+) -> Path:
+ """Get the latest version of a specified layer. Returns cached data if available."""
+
+ # Get layer details
+ layer_details = get_layer_details(
+ conn.session, conn.domain, conn.api_version, layer_id
+ )
+ details_hash = layer_details.get_hex_hash()
+
+ # Check if layer is already cached
+ cache_dir = _get_cache_dir()
+ fn = cache_dir / f"{layer_id}_{layer_details.version_id}_{details_hash}.gpkg"
+
+ if fn.exists():
+ return fn
+
+ # Download the layer
+ output_dir = download_layer(
+ session=conn.session,
+ domain=conn.domain,
+ api_version=conn.api_version,
+ layer_id=layer_id,
+ output_dir=cache_dir,
+ )
+
+ # Find the geopackage in the output directory
+ geopackages = list(output_dir.glob("*.gpkg"))
+ if len(geopackages) != 1:
+ msg = "Should only be one geopackage per export"
+ raise ValueError(msg)
+ (geopackage,) = geopackages
+
+ # Rename the geopackage to the hash
+ geopackage.rename(fn)
+
+ # Remove the content that came with the geopackage
+ if _is_file_in_dir(geopackage, output_dir):
+ msg = "Geopackage should be moved"
+ raise ValueError(msg)
+ shutil.rmtree(output_dir)
+
+ return fn
+
+
+def get_latest_layer_details(conn: KoordinatesConnection, layer_id: int) -> dict:
+ """Get the latest version of a specified layer. Returns cached data if available."""
+ # Get layer details
+ layer_details = get_layer_details(
+ conn.session, conn.domain, conn.api_version, layer_id
+ )
+
+ return layer_details
+
+
+def _get_cache_dir() -> Path:
+ """Get the cache directory."""
+ cache_dir = os.environ.get("KOOPCACHE_DIR")
+ if cache_dir is None:
+ msg = "Please set the KOOPCACHE_DIR environment variable to your desired cache directory."
+ raise ValueError(msg)
+
+ cache_dir = Path(cache_dir)
+
+ if not cache_dir.exists():
+ cache_dir.mkdir(parents=True)
+
+ return cache_dir
+
+
+def _is_file_in_dir(file: Path, directory: Path) -> bool:
+ """Check if a file is in a directory."""
+ try:
+ file.relative_to(directory)
+ return file.exists()
+
+ except ValueError:
+ return False
diff --git a/src/koop/metadata.py b/src/koop/metadata.py
new file mode 100644
index 0000000..9ab1fa8
--- /dev/null
+++ b/src/koop/metadata.py
@@ -0,0 +1,193 @@
+from pathlib import Path
+
+from defusedxml.ElementTree import fromstring
+from pydantic import BaseModel
+
+from koop.backend.api.layers_and_tables.get_details import (
+ get_layer_details,
+ get_layer_metadata,
+)
+from koop.backend.conn import KoordinatesConnection
+
+
+class DublinCoreMetadata(BaseModel):
+ """Dublin Core compliant metadata.
+
+ Attributes:
+ title: The name given to the resource.
+ creator: An entity primarily responsible for making the content of the resource.
+ subject: The topic of the content of the resource.
+ description: An account of the content of the resource.
+ type: The nature or genre of the content of the resource.
+ source: A Reference to a resource from which the present resource is derived.
+ relation: A reference to a related resource.
+ coverage: The extent or scope of the content of the resource.
+ publisher: An entity responsible for making the resource available.
+ contributor: An entity responsible for making contributions to the content of
+ the resource.
+ rights: Information about rights held in and over the resource.
+ date: A date associated with an event in the life cycle of the resource.
+ format: The physical or digital manifestation of the resource.
+ identifier: An unambiguous reference to the resource within a given context.
+ language: A language of the intellectual content of the resource.
+ """
+
+ title: str | None = None
+ creator: str | None = None
+ subject: str | None = None
+ description: str | None = None
+ type: str | None = None
+ source: str | None = None
+ relation: str | None = None
+ coverage: str | None = None
+ publisher: str | None = None
+ contributor: str | None = None
+ rights: str | None = None
+ date: str | None = None
+ format: str | None = None
+ identifier: str | None = None
+ language: str | None = None
+
+ def as_xml(self) -> str:
+ """Convert the metadata to an XML string."""
+ xml = (
+ ''
+ )
+ for key, value in self.model_dump().items():
+ if value:
+ xml += f"{value}"
+ xml += ""
+ return xml
+
+ def to_xml(self, fn: Path) -> None:
+ """Write the metadata to an XML file."""
+ xml = self.as_xml()
+ with fn.open("w") as f:
+ f.write(xml)
+
+ @classmethod
+ def from_raw_xml(cls, xml: str) -> "DublinCoreMetadata":
+ """Create a DublinCoreMetadata object from raw XML."""
+ ns = {"dc": "https://purl.org/dc/elements/1.1/"}
+ root = fromstring(xml)
+ metadata = {}
+
+ for field in cls.model_fields.keys():
+ element = root.find(f"dc:{field}", ns)
+ if element is not None:
+ metadata[field] = element.text
+
+ return cls(**metadata)
+
+ @classmethod
+ def from_koordinates(
+ cls, *, conn: KoordinatesConnection, layer_id: int
+ ) -> "DublinCoreMetadata":
+ """Create a DublinCoreMetadata object by querying Koordinates directly."""
+
+ raw_xml = get_layer_metadata(
+ session=conn.session,
+ domain=conn.domain,
+ api_version=conn.api_version,
+ layer_id=layer_id,
+ )
+
+ return DublinCoreMetadata.from_raw_xml(raw_xml)
+
+ def edit_metadata_fields(self, **kwargs: dict) -> None:
+ """Edit the metadata fields."""
+ for key, value in kwargs.items():
+ if key not in type(self).model_fields:
+ msg = f"Field {key} not in model."
+ raise ValueError(msg)
+ setattr(self, key, value)
+
+ def update_source_field(
+ self,
+ *,
+ conn: KoordinatesConnection,
+ layer_ids: list[int],
+ version_ids: list[int],
+ ) -> None:
+ """Update the source field with the given layer and version IDs."""
+
+ if len(layer_ids) != len(version_ids):
+ msg = "The number of layer IDs and version IDs must be the same."
+ raise ValueError(msg)
+ layers_details = [
+ get_layer_details(
+ session=conn.session,
+ domain=conn.domain,
+ api_version=conn.api_version,
+ layer_id=layer_id,
+ version_id=version_id,
+ )
+ for layer_id, version_id in zip(layer_ids, version_ids, strict=True)
+ ]
+ layer_titles = [layer.title for layer in layers_details]
+ publish_dates = [layer.published_at for layer in layers_details]
+
+ self.source = _create_source_field(
+ layer_ids, layer_titles, version_ids, publish_dates
+ )
+
+ def update_source_with_latest_layer_versions(
+ self, *, conn: KoordinatesConnection, layer_ids: list[int]
+ ) -> None:
+ """Update the source field with the latest versions of the given layer IDs."""
+ layers_details = [
+ get_layer_details(
+ session=conn.session,
+ domain=conn.domain,
+ api_version=conn.api_version,
+ layer_id=layer_id,
+ )
+ for layer_id in layer_ids
+ ]
+ layer_titles = [layer.title for layer in layers_details]
+ version_ids = [layer.version_id for layer in layers_details]
+ publish_dates = [layer.published_at for layer in layers_details]
+
+ self.source = _create_source_field(
+ layer_ids, layer_titles, version_ids, publish_dates
+ )
+
+ @classmethod
+ def populate_template(cls, *, conn: KoordinatesConnection, layer_id: int): # noqa: ANN206
+ """Create a DublinCoreMetadata instance using layer details."""
+
+ layer_details = get_layer_details(
+ session=conn.session,
+ domain=conn.domain,
+ api_version=conn.api_version,
+ layer_id=layer_id,
+ )
+
+ return cls(
+ title=layer_details.title,
+ type=layer_details.kind.name,
+ date=layer_details.published_at,
+ )
+
+
+def _create_source_field(
+ layer_ids: list[int],
+ layer_titles: list[str],
+ version_ids: list[int],
+ dates: list[str],
+) -> str:
+ """Create a source field from the given layer IDs and titles."""
+ csv = "layer_id,title,version_id,last_updated\n"
+ for layer_id, title, version_id, date in zip(
+ layer_ids, layer_titles, version_ids, dates, strict=True
+ ):
+ csv += f"{layer_id},{title},{version_id},{date}\n"
+
+ return csv
diff --git a/src/whitelabel/AGENTS.md b/src/whitelabel/AGENTS.md
deleted file mode 100644
index b0cff80..0000000
--- a/src/whitelabel/AGENTS.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# whitelabel Package Guidelines
-
-## API Communication
-
-- When communicating with APIs, use the `httpx` package unless explicitly instructed otherwise.
-- When using HTTP status codes, use constants from the `http` package instead of hardcoding them (e.g., `http.HTTPStatus.OK`).
-
-## Imports
-
-- When importing internal modules, do not include the "src" folder in the import path as it is already defined in `pyproject.toml`.
-- Example: `from whitelabel.functions import hello_world` instead of `from src.whitelabel.functions import hello_world`.
-
-## Package API Guidelines
-
-- Every package (mypkg/) must define a clear public API in its `__init__.py`.
-- If submodules provide functionality that is likely to be directly consumed, re-export the relevant classes, functions, or constants in `__init__.py`.
-- Use `__all__` to explicitly mark what is public.
-- Internal utilities should remain private (prefix with `_` and do not re-export).
-- This helps consumers of the package avoid deep imports like `mypkg.submodule.foo`.
diff --git a/src/whitelabel/__init__.py b/src/whitelabel/__init__.py
deleted file mode 100644
index 4c1858c..0000000
--- a/src/whitelabel/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""A minimal Python package template."""
-
-__version__ = "0.1.0"
-
-from .functions.hello_world import hello_world
-
-__all__ = ["hello_world"]
diff --git a/src/whitelabel/functions/__init__.py b/src/whitelabel/functions/__init__.py
deleted file mode 100644
index dd1cc87..0000000
--- a/src/whitelabel/functions/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""Functions module for the whitelabel package."""
-
-from .hello_world import hello_world
-
-__all__ = ["hello_world"]
diff --git a/src/whitelabel/functions/hello_world.py b/src/whitelabel/functions/hello_world.py
deleted file mode 100644
index 93a6eae..0000000
--- a/src/whitelabel/functions/hello_world.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""A simple hello world function for the template."""
-
-
-def hello_world(name: str = "World") -> str:
- """Return a greeting message.
-
- Args:
- name: The name to greet. Defaults to "World".
-
- Returns:
- A greeting string.
-
- Examples:
- >>> hello_world()
- 'Hello, World!'
-
- >>> hello_world("Python")
- 'Hello, Python!'
- """
- return f"Hello, {name}!"
diff --git a/tests/assets/metadata.xml b/tests/assets/metadata.xml
new file mode 100644
index 0000000..af57785
--- /dev/null
+++ b/tests/assets/metadata.xml
@@ -0,0 +1,4 @@
+
+ Koop Test
+ Tonkin + Taylor
+
\ No newline at end of file
diff --git a/tests/koop/backend/__init__.py b/tests/koop/backend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/koop/backend/api/__init__.py b/tests/koop/backend/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/koop/backend/api/test_download_layer.py b/tests/koop/backend/api/test_download_layer.py
new file mode 100644
index 0000000..14315a5
--- /dev/null
+++ b/tests/koop/backend/api/test_download_layer.py
@@ -0,0 +1,30 @@
+import os
+
+import pytest
+
+from koop.backend.api.download_layer import download_layer
+
+IS_NOT_PR_BUILD = os.getenv("BITBUCKET_PR_ID") is None
+IS_ON_CI = os.getenv("CI") is not None
+
+
+@pytest.mark.skipif(
+ IS_ON_CI and IS_NOT_PR_BUILD,
+ reason="Only running tests which query the API on PR builds",
+)
+def test_download_layer(tmp_path, koordinates_connection, layer_id):
+ expected_output_dir = tmp_path / "kx-koop-test-GPKG"
+ expected_output_gpkg = expected_output_dir / "koop-test.gpkg"
+ expected_output_txt = expected_output_dir / "koop-test.txt"
+
+ download_layer(
+ session=koordinates_connection.session,
+ domain=koordinates_connection.domain,
+ api_version=koordinates_connection.api_version,
+ layer_id=layer_id,
+ output_dir=tmp_path,
+ )
+
+ assert expected_output_dir.exists()
+ assert expected_output_gpkg.exists()
+ assert expected_output_txt.exists()
diff --git a/tests/koop/conftest.py b/tests/koop/conftest.py
new file mode 100644
index 0000000..1289706
--- /dev/null
+++ b/tests/koop/conftest.py
@@ -0,0 +1,42 @@
+import os
+
+import pytest
+
+from koop.backend.conn import KoordinatesConnection
+
+
+@pytest.fixture()
+def cache_dir(tmp_path_factory):
+ tmp_dir = tmp_path_factory.mktemp("koopcache")
+ # Store the original value of the environment variable
+ original_value = os.environ.get("KOOPCACHE_DIR")
+
+ # Set the environment variable to the temporary directory
+ os.environ["KOOPCACHE_DIR"] = str(tmp_dir)
+
+ yield tmp_dir
+
+ # Restore the original value of the environment variable
+ if original_value is None:
+ del os.environ["KOOPCACHE_DIR"]
+ else:
+ os.environ["KOOPCACHE_DIR"] = original_value
+
+
+@pytest.fixture(scope="package")
+def koordinates_connection():
+ conn = KoordinatesConnection()
+ yield conn
+ conn.close()
+
+
+@pytest.fixture(scope="package")
+def layer_id():
+ return 119171
+
+
+@pytest.fixture()
+def metadata_xml(assets_dir) -> str:
+ fn = assets_dir / "metadata.xml"
+ with fn.open("r") as f:
+ return f.read()
diff --git a/tests/koop/test_get_latest.py b/tests/koop/test_get_latest.py
new file mode 100644
index 0000000..bab8429
--- /dev/null
+++ b/tests/koop/test_get_latest.py
@@ -0,0 +1,101 @@
+import importlib
+import os
+import re
+
+import pytest
+
+import koop.get_latest
+from koop.get_latest import get_latest_layer
+
+IS_NOT_PR_BUILD = os.getenv("BITBUCKET_PR_ID") is None
+IS_ON_CI = os.getenv("CI") is not None
+
+
+@pytest.mark.skipif(
+ IS_ON_CI and IS_NOT_PR_BUILD,
+ reason="Only running tests which query the API on PR builds",
+)
+class TestGetLatest:
+ def test_unit(self, cache_dir, koordinates_connection, layer_id):
+ fn = get_latest_layer(
+ conn=koordinates_connection,
+ layer_id=layer_id,
+ )
+
+ assert fn.exists()
+ assert fn.parent == cache_dir
+ assert re.match(rf"{layer_id}_\d+_[a-fA-F\d]{{32}}.gpkg", fn.name)
+
+ def test_caching(self, cache_dir, koordinates_connection, layer_id, monkeypatch):
+ fn1 = get_latest_layer(
+ conn=koordinates_connection,
+ layer_id=layer_id,
+ )
+ # Sabotage download_layer so it fails if called, ensuring that the function is
+ # using the cache
+ with monkeypatch.context() as m:
+ m.setattr(
+ "koop.backend.api.download_layer.download_layer",
+ lambda **kwargs: kwargs["keyerror"],
+ )
+ # Reimport to ensure the monkeypatch is applied
+ importlib.reload(koop.get_latest)
+
+ fn2 = get_latest_layer(
+ conn=koordinates_connection,
+ layer_id=layer_id,
+ )
+
+ assert fn1 == fn2
+ assert fn1.parent == cache_dir
+
+ # Ensure the monkeypatch was applied
+ fn1.unlink()
+ with pytest.raises(KeyError):
+ get_latest_layer(
+ conn=koordinates_connection,
+ layer_id=layer_id,
+ )
+
+ importlib.reload(koop.get_latest)
+
+ def test_cache_dir_not_set(self, monkeypatch, koordinates_connection, layer_id):
+ with monkeypatch.context() as m:
+ # There is no KOOPCACHE_DIR environment variable set in CI
+ if not os.getenv("CI"):
+ m.delenv("KOOPCACHE_DIR")
+
+ expected_msg = (
+ "Please set the KOOPCACHE_DIR environment variable to your desired"
+ " cache directory."
+ )
+ with pytest.raises(ValueError, match=expected_msg):
+ get_latest_layer(
+ conn=koordinates_connection,
+ layer_id=layer_id,
+ )
+
+ def test_cache_doesnt_have_rubbish(
+ self, cache_dir, koordinates_connection, layer_id
+ ):
+ _ = get_latest_layer(
+ conn=koordinates_connection,
+ layer_id=layer_id,
+ )
+
+ num_cached_files_and_folders = len(list(cache_dir.glob("*")))
+
+ assert num_cached_files_and_folders == 1
+
+ def test_cache_dir_doesnt_exist(
+ self, monkeypatch, koordinates_connection, layer_id, tmp_path
+ ):
+ with monkeypatch.context() as m:
+ cache_dir = tmp_path / "nonexistent"
+ m.setenv("KOOPCACHE_DIR", str(cache_dir))
+ get_latest_layer(
+ conn=koordinates_connection,
+ layer_id=layer_id,
+ )
+
+ assert cache_dir.exists()
diff --git a/tests/koop/test_get_layer_from_id.py b/tests/koop/test_get_layer_from_id.py
new file mode 100644
index 0000000..a893ac6
--- /dev/null
+++ b/tests/koop/test_get_layer_from_id.py
@@ -0,0 +1,16 @@
+import os
+import re
+
+from koop import get_layer_from_id
+
+
+class TestGetFromId:
+ def test_unit(self, cache_dir, layer_id):
+ fn = get_layer_from_id(
+ layer_id=layer_id,
+ api_key=os.getenv("KOORDINATES_API_KEY"),
+ )
+
+ assert fn.exists()
+ assert fn.parent == cache_dir
+ assert re.match(rf"{layer_id}_\d+_[a-fA-F\d]{{32}}.gpkg", fn.name)
diff --git a/tests/koop/test_metadata.py b/tests/koop/test_metadata.py
new file mode 100644
index 0000000..deee334
--- /dev/null
+++ b/tests/koop/test_metadata.py
@@ -0,0 +1,137 @@
+import os
+
+import pytest
+from defusedxml import ElementTree
+
+from koop.backend.api.layers_and_tables.get_details import LayerDetails
+from koop.metadata import DublinCoreMetadata
+
+IS_NOT_PR_BUILD = os.getenv("BITBUCKET_PR_ID") is None
+IS_ON_CI = os.getenv("CI") is not None
+
+
+def _mock_get_layer_details(*args, **kwargs):
+ _ = args, kwargs
+ return LayerDetails(
+ id=1,
+ url="https://open.spotify.com/track/2ylpbsHqGUAv3za4JkyMgz?si=68ccb8ba423d4022",
+ type="mongolian",
+ title="throat singing",
+ kind="vector",
+ published_at="yesterday arvo",
+ metadata=None,
+ version_id=23,
+ )
+
+
+def _mock_get_layer_details_with_metadata(*args, **kwargs):
+ _ = args, kwargs
+ return LayerDetails(
+ id=1,
+ url="https://open.spotify.com/track/2ylpbsHqGUAv3za4JkyMgz?si=68ccb8ba423d4022",
+ type="mongolian",
+ title="throat singing",
+ kind="vector",
+ published_at="yesterday arvo",
+ metadata={
+ "title": "Koop Test",
+ },
+ version_id=23,
+ )
+
+
+def canonicalize(xml_string):
+ """Convert XML string to canonical form."""
+ root = ElementTree.fromstring(xml_string)
+ return ElementTree.tostring(root, encoding="unicode", method="xml")
+
+
+def assert_xml_strings_equal(xml_string1, xml_string2):
+ """Compare two XML strings for equality regardless of element order."""
+ canonical_xml1 = canonicalize(xml_string1)
+ canonical_xml2 = canonicalize(xml_string2)
+ return canonical_xml1 == canonical_xml2
+
+
+class TestDublinCoreMetadata:
+ def test_edit_metadata_fields(self):
+ metadata = DublinCoreMetadata(title="Old Title")
+ metadata.edit_metadata_fields(title="New title", source="BBQ")
+ assert metadata.title == "New title"
+ assert metadata.source == "BBQ"
+
+ def test_update_source_field(self, koordinates_connection, layer_id, monkeypatch):
+ with monkeypatch.context() as m:
+ m.setattr("koop.metadata.get_layer_details", _mock_get_layer_details)
+ metadata = DublinCoreMetadata(source="BBQ")
+ metadata.update_source_field(
+ conn=koordinates_connection,
+ layer_ids=[layer_id],
+ version_ids=[23],
+ )
+ assert metadata.source is not None
+ assert "throat singing" in metadata.source, "Title not in source"
+ assert "yesterday arvo" in metadata.source, "Published date not in source"
+ assert "23" in metadata.source, "Version ID not in source"
+
+ def test_update_source_with_latest_layer_versions(
+ self, koordinates_connection, layer_id, monkeypatch
+ ):
+ with monkeypatch.context() as m:
+ m.setattr("koop.metadata.get_layer_details", _mock_get_layer_details)
+ metadata = DublinCoreMetadata(source="BBQ")
+
+ metadata.update_source_with_latest_layer_versions(
+ conn=koordinates_connection, layer_ids=[layer_id]
+ )
+ assert metadata.source is not None
+ assert "throat singing" in metadata.source, "Title not in source"
+ assert "yesterday arvo" in metadata.source, "Published date not in source"
+ assert "23" in metadata.source, "Version ID not in source"
+
+ def test_populate_template(self, koordinates_connection, layer_id, monkeypatch):
+ with monkeypatch.context() as m:
+ m.setattr("koop.metadata.get_layer_details", _mock_get_layer_details)
+ metadata = DublinCoreMetadata.populate_template(
+ conn=koordinates_connection, layer_id=layer_id
+ )
+ assert metadata.title == "throat singing"
+ assert metadata.type == "vector"
+ assert metadata.date == "yesterday arvo"
+
+ def test_xml_reading_and_writing(self, metadata_xml):
+ metadata = DublinCoreMetadata.from_raw_xml(metadata_xml)
+ assert metadata.title == "Koop Test"
+ assert metadata.creator == "Tonkin + Taylor"
+
+ new_xml = metadata.as_xml()
+ assert_xml_strings_equal(metadata_xml, new_xml)
+
+ def test_from_koordinates_no_metadata(
+ self, koordinates_connection, layer_id, monkeypatch
+ ):
+ with monkeypatch.context() as m:
+ m.setattr(
+ "koop.backend.api.layers_and_tables.get_details.get_layer_details",
+ _mock_get_layer_details,
+ )
+
+ with pytest.raises(ValueError, match="Layer has no metadata."):
+ DublinCoreMetadata.from_koordinates(
+ conn=koordinates_connection, layer_id=layer_id
+ )
+
+ @pytest.mark.skip(reason="Haven't figured out quite how I want to test this yet")
+ def test_from_koordinates_with_metadata(
+ self, koordinates_connection, layer_id, monkeypatch
+ ):
+ with monkeypatch.context() as m:
+ m.setattr(
+ "koop.backend.api.layers_and_tables.get_details.get_layer_details",
+ _mock_get_layer_details_with_metadata,
+ )
+
+ metadata = DublinCoreMetadata.from_koordinates(
+ conn=koordinates_connection, layer_id=layer_id
+ )
+ assert metadata.title == "Koop Test"
diff --git a/tests/whitelabel/test_hello_world.py b/tests/whitelabel/test_hello_world.py
deleted file mode 100644
index 852f782..0000000
--- a/tests/whitelabel/test_hello_world.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Tests for the hello_world function."""
-
-from whitelabel.functions.hello_world import hello_world
-
-
-def test_hello_world():
- """Test hello_world function returns expected greeting."""
- result = hello_world("World")
- assert result == "Hello, World!"
diff --git a/uv.lock b/uv.lock
index a41f865..a3ce243 100644
--- a/uv.lock
+++ b/uv.lock
@@ -521,6 +521,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
]
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
[[package]]
name = "deptry"
version = "0.23.0"
@@ -1101,6 +1110,79 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/1d/50ad811d1c5dae091e4cf046beba925bcae0a610e79ae4c538f996f63ed5/kiwisolver-1.4.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:65ea09a5a3faadd59c2ce96dc7bf0f364986a315949dc6374f04396b0d60e09b", size = 71762, upload-time = "2024-12-24T18:30:48.903Z" },
]
+[[package]]
+name = "koop"
+source = { editable = "." }
+dependencies = [
+ { name = "defusedxml" },
+ { name = "pydantic" },
+ { name = "requests" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "build" },
+ { name = "deptry" },
+ { name = "ipykernel" },
+ { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "ipython", version = "9.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "ipywidgets" },
+ { name = "matplotlib" },
+ { name = "nbstripout" },
+ { name = "perfplot" },
+ { name = "pip" },
+ { name = "pre-commit" },
+ { name = "pre-commit-update" },
+ { name = "pyright", extra = ["nodejs"] },
+ { name = "ruff" },
+ { name = "tqdm" },
+ { name = "usethis" },
+]
+doc = [
+ { name = "mkdocs" },
+]
+test = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "pytest" },
+ { name = "pytest-cov" },
+ { name = "pytest-emoji" },
+ { name = "pytest-md" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "defusedxml", specifier = ">=0.7.1" },
+ { name = "pydantic", specifier = ">=2.0.0" },
+ { name = "requests", specifier = ">=2.0.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "build", specifier = ">=1.2.2.post1" },
+ { name = "deptry", specifier = ">=0.23.0" },
+ { name = "ipykernel", specifier = ">=6.30.0" },
+ { name = "ipython", specifier = ">=8.37.0" },
+ { name = "ipywidgets", specifier = ">=8.1.7" },
+ { name = "matplotlib", specifier = ">=3.10.3" },
+ { name = "nbstripout", specifier = ">=0.8.1" },
+ { name = "perfplot", specifier = ">=0.10.2" },
+ { name = "pip", specifier = ">=25.1.1" },
+ { name = "pre-commit", specifier = ">=4.2.0" },
+ { name = "pre-commit-update", specifier = ">=0.8.0" },
+ { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.403" },
+ { name = "ruff", specifier = ">=0.12.5" },
+ { name = "tqdm", specifier = ">=4.67.1" },
+ { name = "usethis", specifier = ">=0.15.2" },
+]
+doc = [{ name = "mkdocs", specifier = ">=1.6.1" }]
+test = [
+ { name = "coverage", extras = ["toml"], specifier = ">=7.10.1" },
+ { name = "pytest", specifier = ">=8.4.1" },
+ { name = "pytest-cov", specifier = ">=6.2.1" },
+ { name = "pytest-emoji", specifier = ">=0.2.0" },
+ { name = "pytest-md", specifier = ">=0.2.0" },
+]
+
[[package]]
name = "markdown"
version = "3.8.2"
@@ -2662,69 +2744,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" },
]
-[[package]]
-name = "whitelabel"
-source = { editable = "." }
-
-[package.dev-dependencies]
-dev = [
- { name = "build" },
- { name = "deptry" },
- { name = "ipykernel" },
- { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
- { name = "ipython", version = "9.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
- { name = "ipywidgets" },
- { name = "matplotlib" },
- { name = "nbstripout" },
- { name = "perfplot" },
- { name = "pip" },
- { name = "pre-commit" },
- { name = "pre-commit-update" },
- { name = "pyright", extra = ["nodejs"] },
- { name = "ruff" },
- { name = "tqdm" },
- { name = "usethis" },
-]
-doc = [
- { name = "mkdocs" },
-]
-test = [
- { name = "coverage", extra = ["toml"] },
- { name = "pytest" },
- { name = "pytest-cov" },
- { name = "pytest-emoji" },
- { name = "pytest-md" },
-]
-
-[package.metadata]
-
-[package.metadata.requires-dev]
-dev = [
- { name = "build", specifier = ">=1.2.2.post1" },
- { name = "deptry", specifier = ">=0.23.0" },
- { name = "ipykernel", specifier = ">=6.30.0" },
- { name = "ipython", specifier = ">=8.37.0" },
- { name = "ipywidgets", specifier = ">=8.1.7" },
- { name = "matplotlib", specifier = ">=3.10.3" },
- { name = "nbstripout", specifier = ">=0.8.1" },
- { name = "perfplot", specifier = ">=0.10.2" },
- { name = "pip", specifier = ">=25.1.1" },
- { name = "pre-commit", specifier = ">=4.2.0" },
- { name = "pre-commit-update", specifier = ">=0.8.0" },
- { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.403" },
- { name = "ruff", specifier = ">=0.12.5" },
- { name = "tqdm", specifier = ">=4.67.1" },
- { name = "usethis", specifier = ">=0.15.2" },
-]
-doc = [{ name = "mkdocs", specifier = ">=1.6.1" }]
-test = [
- { name = "coverage", extras = ["toml"], specifier = ">=7.10.1" },
- { name = "pytest", specifier = ">=8.4.1" },
- { name = "pytest-cov", specifier = ">=6.2.1" },
- { name = "pytest-emoji", specifier = ">=0.2.0" },
- { name = "pytest-md", specifier = ">=0.2.0" },
-]
-
[[package]]
name = "widgetsnbextension"
version = "4.0.14"