Skip to content

Commit

Permalink
feat: Collection.replace_service
Browse files Browse the repository at this point in the history
  • Loading branch information
smotornyuk committed Jan 5, 2024
1 parent dcf65b0 commit a34518b
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 33 deletions.
13 changes: 12 additions & 1 deletion ckanext/collection/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 13 additions & 1 deletion ckanext/collection/tests/test_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand All @@ -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."""
Expand Down
42 changes: 42 additions & 0 deletions ckanext/collection/tests/utils/test_collection.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 0 additions & 5 deletions ckanext/collection/tests/utils/test_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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))
Expand All @@ -75,7 +72,6 @@ def test_output(self, collection: StaticCollection):
dataset_labels={"age": "Age"},
colors={"age": "test"},
)
collection.serializer = serializer

output = serializer.render().strip()

Expand All @@ -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 = (
Expand Down
43 changes: 36 additions & 7 deletions ckanext/collection/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -35,21 +48,25 @@ 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.
In classic pager it may be the number of items per page. For date range
pager, it can be a timespan within which we are searching for records.
"""
return self.start - self.end
...

@abc.abstractproperty
def start(self) -> Any:
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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."""
Expand Down
40 changes: 35 additions & 5 deletions ckanext/collection/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
56 changes: 46 additions & 10 deletions ckanext/collection/utils/collection.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -96,27 +104,55 @@ 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}")
value = maker(**kwargs.get(f"{name}_settings", {}))

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)

Expand Down
8 changes: 4 additions & 4 deletions ckanext/collection/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit a34518b

Please sign in to comment.