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"