diff --git a/ckanext/collection/shared.py b/ckanext/collection/shared.py index 09f786d..16f6f4f 100644 --- a/ckanext/collection/shared.py +++ b/ckanext/collection/shared.py @@ -11,7 +11,12 @@ import ckan.plugins.toolkit as tk -from ckanext.collection.types import BaseCollection, CollectionFactory, TDataCollection +from ckanext.collection.types import ( + BaseCollection, + CollectionFactory, + TDataCollection, + Service, +) T = TypeVar("T") SENTINEL = object() @@ -59,6 +64,12 @@ class AttachTrait(abc.ABC, Generic[TDataCollection]): def _attach(self, obj: TDataCollection): self.__collection = obj + if isinstance(self, Service): + # this block allows attaching services to non-collections. Mainly + # it's used in tests + replace_service = getattr(obj, "replace_service", None) + if replace_service: + replace_service(self) @property def attached(self) -> TDataCollection: diff --git a/ckanext/collection/tests/test_shared.py b/ckanext/collection/tests/test_shared.py index 0319219..1a147dd 100644 --- a/ckanext/collection/tests/test_shared.py +++ b/ckanext/collection/tests/test_shared.py @@ -7,7 +7,7 @@ import ckan.plugins.toolkit as tk -from ckanext.collection import shared +from ckanext.collection import shared, utils class AttachExample(shared.AttachTrait[Any]): @@ -25,6 +25,18 @@ def test_attached_object(obj: Any): assert example.attached is obj +def test_service_attach_updates_collection(): + """Anything can be attached as a collection to AttachTrait implementation.""" + collection = utils.Collection("", {}) + example = AttachExample() + example._attach(collection) + assert collection.data is not example + + data = utils.Data(None) + data._attach(collection) + assert collection.data is data + + class TestAttrSettings: def test_default_factories(self, faker: Faker): """Default factories are used when value is missing from settings.""" diff --git a/ckanext/collection/tests/utils/test_collection.py b/ckanext/collection/tests/utils/test_collection.py index e69de29..cf0bb70 100644 --- a/ckanext/collection/tests/utils/test_collection.py +++ b/ckanext/collection/tests/utils/test_collection.py @@ -0,0 +1,42 @@ +from __future__ import annotations +from typing import Any +import pytest + +from ckanext.collection.utils import * + + +class TestCollection: + def test_basic_collection_creation(self): + obj = Collection("", {}) + assert isinstance(obj.columns, Columns) + assert isinstance(obj.pager, ClassicPager) + assert isinstance(obj.filters, Filters) + assert isinstance(obj.data, Data) + assert isinstance(obj.serializer, Serializer) + + def test_custom_instance(self): + data = Data(None) + + collection = Collection("", {}, data_instance=data) + assert collection.data is data + assert data.attached is collection + + def test_custom_factory(self): + custom_factory = Data[Any, Any].with_attributes() + collection = Collection[Any]("", {}, data_factory=custom_factory) + + assert isinstance(collection.data, custom_factory) + + def test_replace_service(self): + collection = Collection("", {}) + data = Data(None) + columns = Columns(None) + + assert collection.data is not data + assert collection.columns is not columns + + collection.replace_service(data) + collection.replace_service(columns) + + assert collection.data is data + assert collection.columns is columns diff --git a/ckanext/collection/tests/utils/test_serializer.py b/ckanext/collection/tests/utils/test_serializer.py index 736ab82..2b18b42 100644 --- a/ckanext/collection/tests/utils/test_serializer.py +++ b/ckanext/collection/tests/utils/test_serializer.py @@ -34,7 +34,6 @@ def collection(): class TestCsvSerializer: def test_output(self, collection: StaticCollection): serializer = serialize.CsvSerializer(collection) - collection.serializer = serializer output = serializer.render().strip() nl = csv.get_dialect("excel").lineterminator @@ -46,7 +45,6 @@ def test_output(self, collection: StaticCollection): class TestJsonlSerializer: def test_output(self, collection: StaticCollection): serializer = serialize.JsonlSerializer(collection) - collection.serializer = serializer output = serializer.render().strip() nl = "\n" @@ -58,7 +56,6 @@ def test_output(self, collection: StaticCollection): class TestJsonSerializer: def test_output(self, collection: StaticCollection): serializer = serialize.JsonSerializer(collection) - collection.serializer = serializer output = serializer.render().strip() expected_output = json.dumps(list(collection.data)) @@ -75,7 +72,6 @@ def test_output(self, collection: StaticCollection): dataset_labels={"age": "Age"}, colors={"age": "test"}, ) - collection.serializer = serializer output = serializer.render().strip() @@ -95,7 +91,6 @@ class TestHtmlSerializer: @pytest.mark.usefixtures("with_request_context") def test_output(self, collection: StaticCollection, ckan_config: Any): serializer = serialize.HtmlSerializer(collection) - collection.serializer = serializer output = serializer.render().strip() expected_output = ( diff --git a/ckanext/collection/types.py b/ckanext/collection/types.py index f2dc1b3..56720ea 100644 --- a/ckanext/collection/types.py +++ b/ckanext/collection/types.py @@ -12,7 +12,16 @@ TData = TypeVar("TData") -class BaseColumns(abc.ABC): +class Service: + """Marker for service classes used by collection.""" + + @abc.abstractproperty + def service_name(self) -> str: + """Name of the service instance used by collection.""" + ... + + +class BaseColumns(abc.ABC, Service): """Declaration of columns properties""" names: list[str] @@ -21,8 +30,12 @@ class BaseColumns(abc.ABC): filterable: set[str] labels: dict[str, str] + @property + def service_name(self): + return "columns" + -class BaseData(abc.ABC, Sized, Iterable[TData]): +class BaseData(abc.ABC, Sized, Iterable[TData], Service): """Declaration of data properties.""" @abc.abstractproperty @@ -35,13 +48,17 @@ def range(self, start: Any, end: Any) -> Iterable[TData]: """Slice data using specified limits.""" ... + @property + def service_name(self): + return "data" + -class BasePager(abc.ABC): +class BasePager(abc.ABC, Service): """Declaration of pager properties""" params: dict[str, Any] - @property + @abc.abstractproperty def size(self) -> Any: """Range of the pager. @@ -49,7 +66,7 @@ def size(self) -> Any: pager, it can be a timespan within which we are searching for records. """ - return self.start - self.end + ... @abc.abstractproperty def start(self) -> Any: @@ -71,8 +88,12 @@ def end(self) -> Any: """ ... + @property + def service_name(self): + return "pager" + -class BaseSerializer(abc.ABC): +class BaseSerializer(abc.ABC, Service): @abc.abstractmethod def stream(self) -> Iterable[str] | Iterable[bytes]: """Iterate over fragments of the content.""" @@ -83,6 +104,10 @@ def render(self) -> str | bytes: """Combine content fragments into a single dump.""" ... + @property + def service_name(self): + return "serializer" + class BaseCollection(abc.ABC, Iterable[TData]): """Declaration of collection properties.""" @@ -97,12 +122,16 @@ class BaseCollection(abc.ABC, Iterable[TData]): serializer: BaseSerializer -class BaseFilters(abc.ABC): +class BaseFilters(abc.ABC, Service): """Declaration of filters properties.""" filters: list[Filter[Any]] actions: list[Filter[Any]] + @property + def service_name(self): + return "filters" + class Filter(TypedDict, Generic[TFilterOptions]): """Filter details.""" diff --git a/ckanext/collection/utils/__init__.py b/ckanext/collection/utils/__init__.py index bc4917b..18551be 100644 --- a/ckanext/collection/utils/__init__.py +++ b/ckanext/collection/utils/__init__.py @@ -1,8 +1,38 @@ -from .collection import Collection +from .collection import Collection, ApiCollection, ModelCollection from .columns import Columns -from .data import Data +from .data import Data, ModelData, ApiData, ApiListData, ApiSearchData from .filters import Filters -from .pager import Pager -from .serialize import Serializer +from .pager import Pager, ClassicPager +from .serialize import ( + Serializer, + CsvSerializer, + JsonSerializer, + JsonlSerializer, + ChartJsSerializer, + HtmlSerializer, + TableSerializer, + HtmxTableSerializer, +) -__all__ = ["Serializer", "Filters", "Pager", "Collection", "Data", "Columns"] +__all__ = [ + "ApiCollection", + "ApiData", + "ApiListData", + "ApiSearchData", + "ChartJsSerializer", + "ClassicPager", + "Collection", + "Columns", + "CsvSerializer", + "Data", + "Filters", + "HtmlSerializer", + "HtmxTableSerializer", + "JsonSerializer", + "JsonlSerializer", + "ModelCollection", + "ModelData", + "Pager", + "Serializer", + "TableSerializer", +] diff --git a/ckanext/collection/utils/collection.py b/ckanext/collection/utils/collection.py index d6bd38a..165537b 100644 --- a/ckanext/collection/utils/collection.py +++ b/ckanext/collection/utils/collection.py @@ -1,10 +1,10 @@ from __future__ import annotations -from typing import Any, Iterator +from typing import Any, Iterator, overload from typing_extensions import Self -from ckanext.collection import types +from ckanext.collection import types, shared from .data import Data, StaticData, ModelData, ApiData, ApiSearchData, ApiListData from .columns import Columns @@ -78,6 +78,14 @@ class Collection(types.BaseCollection[types.TData]): SerializerFactory: type[Serializer[Self]] = Serializer PagerFactory: type[Pager[Self]] = ClassicPager + _service_names: tuple[str, ...] = ( + "columns", + "pager", + "filters", + "data", + "serializer", + ) + def __init__(self, name: str, params: dict[str, Any], /, **kwargs: Any): """Use name to pick only relevant parameters. @@ -96,20 +104,17 @@ def __init__(self, name: str, params: dict[str, Any], /, **kwargs: Any): } self.params = params - self.columns = self._instantiate("columns", kwargs) - self.pager = self._instantiate("pager", kwargs) - self.filters = self._instantiate("filters", kwargs) - self.data = self._instantiate("data", kwargs) - self.serializer = self._instantiate("serializer", kwargs) + for service in self._service_names: + self.replace_service(self._instantiate(service, kwargs)) def _instantiate(self, name: str, kwargs: dict[str, Any]) -> Any: if factory := kwargs.get(f"{name}_factory"): fn = "".join(p.capitalize() for p in name.split("_")) setattr(self, fn + "Factory", factory) - value = kwargs.get(f"{name}_instance") - if value: - value.attach(self) + value: shared.Domain[Any] | None = kwargs.get(f"{name}_instance") + if value is not None: + value._attach(self) # pyright: ignore [reportPrivateUsage] else: maker = getattr(self, f"make_{name}") @@ -117,6 +122,37 @@ def _instantiate(self, name: str, kwargs: dict[str, Any]) -> Any: return value + @overload + def replace_service(self, service: types.BaseColumns) -> types.BaseColumns | None: + ... + + @overload + def replace_service( + self, service: types.BaseData[Any] + ) -> types.BaseData[Any] | None: + ... + + @overload + def replace_service(self, service: types.BasePager) -> types.BasePager | None: + ... + + @overload + def replace_service(self, service: types.BaseFilters) -> types.BaseFilters | None: + ... + + @overload + def replace_service( + self, service: types.BaseSerializer + ) -> types.BaseSerializer | None: + ... + + def replace_service(self, service: types.Service) -> types.Service | None: + """Attach service to collection + """ + old_service = getattr(self, service.service_name, None) + setattr(self, service.service_name, service) + return old_service + def __iter__(self) -> Iterator[types.TData]: yield from self.data.range(self.pager.start, self.pager.end) diff --git a/ckanext/collection/views.py b/ckanext/collection/views.py index 54563cb..62b794e 100644 --- a/ckanext/collection/views.py +++ b/ckanext/collection/views.py @@ -39,13 +39,13 @@ def export(name: str, format: str) -> types.Response: params = parse_params(tk.request.args) collection = shared.get_collection(name, params) - serializer = config.serializer(format) + serializer_factory = config.serializer(format) - if not collection or not serializer: + if not collection or not serializer_factory: return tk.abort(404) - collection.serializer = serializer(collection) + serializer = serializer_factory(collection) - resp = streaming_response(collection.serializer.stream(), with_context=True) + resp = streaming_response(serializer.stream(), with_context=True) resp.headers["Content-Disposition"] = f"attachment; filename=collection.{format}" return resp