diff --git a/.github/workflows/pytest-verify.yml b/.github/workflows/pytest-verify.yml new file mode 100644 index 0000000..01d6aa3 --- /dev/null +++ b/.github/workflows/pytest-verify.yml @@ -0,0 +1,30 @@ +name: Run pytest on supported python versions + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Test with pytest + run: | + pytest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..abc7ed2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +ARG PYTHON_VERSION=3.11 + + +FROM python:$PYTHON_VERSION + +WORKDIR /app + +COPY pyproject.toml . +COPY README.md . +COPY requirements* . + +RUN pip install -r requirements.txt +RUN pip install . + + +ENTRYPOINT ["python", "-m", "pytest"] + + diff --git a/README.md b/README.md index 9085429..5ce216b 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,12 @@ A Collection of python utility functions Primarily focused on integration with Funidata APIs +Install dependencies in local virtual environment using the pyproject.toml : +`pip install .` + +Include dev dependencies for testing etc: +`pip install -r requirements.txt` + ### TODO Finish readme documentation \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..8a055ea --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,27 @@ +x-tests-common: &tests-common + volumes: + - ./tests:/app/tests + - ./funidata_utils:/app/funidata_utils + + +services: + pytest_311: + build: + context: . + args: + - PYTHON_VERSION=3.11 + <<: *tests-common + + pytest_312: + build: + context: . + args: + - PYTHON_VERSION=3.12 + <<: *tests-common + + pytest_313: + build: + context: . + args: + - PYTHON_VERSION=3.13 + <<: *tests-common diff --git a/funidata_utils/compat/utils_312.py b/funidata_utils/compat/utils_312.py new file mode 100644 index 0000000..7ce0371 --- /dev/null +++ b/funidata_utils/compat/utils_312.py @@ -0,0 +1,10 @@ +from collections import defaultdict +from functools import reduce +from typing import Callable, Any + + +def group_by[T]( + seq: list[T], + key: Callable +) -> dict[Any, list[T]]: + return reduce(lambda grp, val: grp[key(val)].append(val) or grp, seq, defaultdict(list)) diff --git a/funidata_utils/compat/utils_legacy.py b/funidata_utils/compat/utils_legacy.py new file mode 100644 index 0000000..843eaf0 --- /dev/null +++ b/funidata_utils/compat/utils_legacy.py @@ -0,0 +1,13 @@ +from collections import defaultdict +from functools import reduce +from typing import Callable, Any, TypeVar + + +T = TypeVar('T') + + +def group_by( + seq: list[T], + key: Callable +) -> dict[Any, list[T]]: + return reduce(lambda grp, val: grp[key(val)].append(val) or grp, seq, defaultdict(list)) diff --git a/funidata_utils/schemas/common_serializers.py b/funidata_utils/schemas/common_serializers.py index c55f679..1af1546 100644 --- a/funidata_utils/schemas/common_serializers.py +++ b/funidata_utils/schemas/common_serializers.py @@ -1,12 +1,7 @@ -from typing import Iterable +import sys -def serialize_as_list[typevar](v: set[typevar] | list[typevar] | None) -> list[typevar] | None: - if v is None: - return None - - try: - return list(set(v)) - except Exception as e: - # If it can't be hashed to set, just return as list - return list(v) +if sys.version_info >= (3, 12): + from .compat.common_serializers_312 import serialize_as_list # noqa: F401 ("Unused import") +else: + from .compat.common_serializers_legacy import serialize_as_list # noqa: F401 ("Unused import") diff --git a/funidata_utils/schemas/compat/common_serializers_312.py b/funidata_utils/schemas/compat/common_serializers_312.py new file mode 100644 index 0000000..e148608 --- /dev/null +++ b/funidata_utils/schemas/compat/common_serializers_312.py @@ -0,0 +1,12 @@ +# Python > 3.12 implementations + + +def serialize_as_list[typevar](v: set[typevar] | list[typevar] | None) -> list[typevar] | None: + if v is None: + return None + + try: + return list(set(v)) + except Exception as e: + # If it can't be hashed to set, just return as list + return list(v) diff --git a/funidata_utils/schemas/compat/common_serializers_legacy.py b/funidata_utils/schemas/compat/common_serializers_legacy.py new file mode 100644 index 0000000..e80cbc4 --- /dev/null +++ b/funidata_utils/schemas/compat/common_serializers_legacy.py @@ -0,0 +1,15 @@ +from typing import TypeVar + + +T = TypeVar('T') + + +def serialize_as_list(v: set[T] | list[T] | None) -> list[T] | None: + if v is None: + return None + + try: + return list(set(v)) + except Exception as e: + # If it can't be hashed to set, just return as list + return list(v) diff --git a/funidata_utils/sis_integration/async_imports.py b/funidata_utils/sis_integration/async_imports.py index ed3e924..045ac99 100644 --- a/funidata_utils/sis_integration/async_imports.py +++ b/funidata_utils/sis_integration/async_imports.py @@ -22,11 +22,12 @@ async def import_to_sisu( resource: SisImportable, use_legacy_import: Literal[False], fp: IO, - batch_size: int | None, - binary_search_max_depth: int | None, - group_by_key: str | None, - binary_err_search_sublists: bool, - max_parallel_requests: int, + batch_size: int | None = UNSET_BATCH_SIZE, + binary_search_max_depth: int | None = 0, + group_by_key: str | None = None, + binary_err_search_sublists: bool = False, + max_parallel_requests: int = 1, + params: dict | None = None, ) -> list[httpx.Response]: ... @@ -37,11 +38,12 @@ async def import_to_sisu( resource: SisLegacyImportable, use_legacy_import: Literal[True], fp: IO, - batch_size: int | None, - binary_search_max_depth: int | None, - group_by_key: str | None, - binary_err_search_sublists: bool, - max_parallel_requests: int, + batch_size: int | None = UNSET_BATCH_SIZE, + binary_search_max_depth: int | None = 0, + group_by_key: str | None = None, + binary_err_search_sublists: bool = False, + max_parallel_requests: int = 1, + params: dict | None = None, ) -> list[httpx.Response]: ... @@ -52,11 +54,12 @@ async def import_to_sisu( resource: SisImportable, use_legacy_import: Literal[False], data: list[dict], - batch_size: int | None, - binary_search_max_depth: int | None, - group_by_key: str | None, - binary_err_search_sublists: bool, - max_parallel_requests: int, + batch_size: int | None = UNSET_BATCH_SIZE, + binary_search_max_depth: int | None = 0, + group_by_key: str | None = None, + binary_err_search_sublists: bool = False, + max_parallel_requests: int = 1, + params: dict | None = None, ) -> list[httpx.Response]: ... @@ -67,11 +70,12 @@ async def import_to_sisu( resource: SisLegacyImportable, use_legacy_import: Literal[True], data: list[dict], - batch_size: int | None, - binary_search_max_depth: int | None, - group_by_key: str | None, - binary_err_search_sublists: bool, - max_parallel_requests: int, + batch_size: int | None = UNSET_BATCH_SIZE, + binary_search_max_depth: int | None = 0, + group_by_key: str | None = None, + binary_err_search_sublists: bool = False, + max_parallel_requests: int = 1, + params: dict | None = None, ) -> list[httpx.Response]: ... @@ -86,7 +90,8 @@ async def import_to_sisu( binary_search_max_depth: int | None = 0, group_by_key: str | None = None, binary_err_search_sublists: bool = False, - max_parallel_requests: int = 1 + max_parallel_requests: int = 1, + params: dict | None = None, ) -> list[httpx.Response]: if fp: raise NotImplementedError("Not yet implemented") @@ -112,6 +117,7 @@ async def import_to_sisu( group_by_key=group_by_key, method='POST', max_parallel_requests=max_parallel_requests, + params=params, ) return responses @@ -127,7 +133,8 @@ async def patch_to_sisu( binary_search_max_depth: int | None = 0, group_by_key: str | None = None, binary_err_search_sublists: bool = False, - max_parallel_requests: int = 1 + max_parallel_requests: int = 1, + params: dict | None = None, ) -> list[httpx.Response]: if fp: raise NotImplementedError("Not yet implemented") @@ -153,6 +160,7 @@ async def patch_to_sisu( group_by_key=group_by_key, method='PATCH', max_parallel_requests=max_parallel_requests, + params=params, ) return responses diff --git a/funidata_utils/sis_integration/exports.py b/funidata_utils/sis_integration/exports.py index 8857554..d7b56b5 100644 --- a/funidata_utils/sis_integration/exports.py +++ b/funidata_utils/sis_integration/exports.py @@ -15,7 +15,8 @@ def _export_from_endpoint( fp: None, since_ordinal: int = 0, export_limit: int = 1000, - since: str = 'since' + since: str = 'since', + params: dict | None = None, ) -> list[dict]: ... @@ -27,7 +28,8 @@ def _export_from_endpoint( fp: IO, since_ordinal: int = 0, export_limit: int = 1000, - since: str = 'since' + since: str = 'since', + params: dict | None = None, ) -> TextIO: ... @@ -38,8 +40,12 @@ def _export_from_endpoint( fp: IO | None, since_ordinal: int = 0, export_limit: int = 1000, - since: str = 'since' + since: str = 'since', + params: dict | None = None, ) -> IO | list[dict]: + if not params: + params = {} + exported_entities = [] for entities in export_from_endpoint_generator( sis_settings=sis_settings, @@ -47,6 +53,7 @@ def _export_from_endpoint( since_ordinal=since_ordinal, export_limit=export_limit, since=since, + params=params, ): if fp is None: exported_entities += entities @@ -69,16 +76,19 @@ def export_from_endpoint_generator( endpoint: str, since_ordinal: int = 0, export_limit: int = 1000, - since: str = 'since' + since: str = 'since', + params: dict | None = None, ) -> Generator[list[dict], None, None]: greatest_ordinal = since_ordinal export_limit = export_limit + if not params: + params = {} while True: sis_response = send_get_httpx( path=f"{sis_settings.host}{endpoint}", auth=sis_settings.get_export_auth(), - params={since: greatest_ordinal, 'limit': export_limit}, + params=params | {since: greatest_ordinal, 'limit': export_limit}, proxies=sis_settings.proxies, ) if sis_response.status_code == 200: @@ -99,10 +109,10 @@ def export_from_endpoint_generator( def export_from_sisu( sisu_config: SupportsExportAuthentication, resource: SisExportable, - fp: None, since_ordinal: int, - as_generator: Literal[False] + params: dict | None = None, ) -> list[dict]: + # Regular call, no generator or FP reference ... @@ -110,10 +120,11 @@ def export_from_sisu( def export_from_sisu( sisu_config: SupportsExportAuthentication, resource: SisExportable, - fp: IO, since_ordinal: int, - as_generator: Literal[False] -) -> IO: + as_generator: Literal[False], + params: dict | None = None, +) -> list[dict]: + # Regular call, generator explicit false, no FP reference ... @@ -121,10 +132,23 @@ def export_from_sisu( def export_from_sisu( sisu_config: SupportsExportAuthentication, resource: SisExportable, - fp: None, since_ordinal: int, - as_generator: Literal[True] + as_generator: Literal[True], + params: dict | None = None, ) -> Generator[list[dict], None, None]: + # Call with as_generator does not allow FP reference + ... + + +@overload +def export_from_sisu( + sisu_config: SupportsExportAuthentication, + resource: SisExportable, + fp: IO, + since_ordinal: int, + params: dict | None = None, +) -> IO: + # Call with FP reference does not allow as_generator ... @@ -133,7 +157,8 @@ def export_from_sisu( resource: SisExportable, fp: IO | None = None, since_ordinal: int = 0, - as_generator: bool = False + as_generator: bool = False, + params: dict | None = None, ) -> list[dict] | IO | Generator[list[dict], None, None]: if as_generator: return export_from_endpoint_generator( @@ -142,6 +167,7 @@ def export_from_sisu( sis_settings=sisu_config, since_ordinal=since_ordinal, since=resource.exports.since, + params=params, ) if fp: @@ -151,7 +177,8 @@ def export_from_sisu( sis_settings=sisu_config, since_ordinal=since_ordinal, since=resource.exports.since, - fp=fp + fp=fp, + params=params, ) return _export_from_endpoint( @@ -160,5 +187,6 @@ def export_from_sisu( sis_settings=sisu_config, since_ordinal=since_ordinal, since=resource.exports.since, - fp=None + fp=None, + params=params ) diff --git a/funidata_utils/utils.py b/funidata_utils/utils.py index a9dcd16..0a234ce 100644 --- a/funidata_utils/utils.py +++ b/funidata_utils/utils.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Funidata Oy. # All rights reserved. # ------------------------------------------------------------------------------ - +import sys from collections import defaultdict from functools import reduce from statistics import mean, stdev @@ -10,6 +10,12 @@ import httpx +if sys.version_info >= (3, 12): + from .compat.utils_312 import group_by # noqa: F401 ("Unused import") +else: + from .compat.utils_legacy import group_by # noqa: F401 ("Unused import") + + def _recursive_flatten( lst: list ) -> Generator: @@ -27,13 +33,6 @@ def flatten( return list(_recursive_flatten(lst)) -def group_by[T]( - seq: list[T], - key: Callable -) -> dict[Any, list[T]]: - return reduce(lambda grp, val: grp[key(val)].append(val) or grp, seq, defaultdict(list)) - - def group_indexes_by( seq: list, key: Callable diff --git a/pyproject.toml b/pyproject.toml index ba08b76..1463d9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,12 +6,14 @@ build-backend = "pdm.backend" name = "funidata-utils" dynamic = ["version"] description = "A collection of utility packages for interacting with Funidata products" -requires-python = ">=3.12" +requires-python = ">=3.11" readme = "README.md" classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python", "Framework :: Pydantic", "Framework :: Pydantic :: 2", @@ -50,10 +52,7 @@ version = { source = "scm", fallback_version = "0.0.0" } [tool.pdm.build] -source-includes = [ - "tests/", - "requirements*.txt", -] +excludes = ["tests/"] [tool.pytest.ini_options] addopts = [ diff --git a/requirements-tests.txt b/requirements-tests.txt deleted file mode 100644 index dd54f63..0000000 --- a/requirements-tests.txt +++ /dev/null @@ -1,3 +0,0 @@ --e .[all] -pytest >=8.4.1,<9.0.0 -coverage[toml] >= 7.9.2,< 8.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 37158c8..163b13c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ -e .[all] --r requirements-tests.txt \ No newline at end of file +pytest>=9.1.1, <10.0.0 +coverage[toml] >= 7.9.2,< 8.0 +pytest-asyncio==1.4.0 \ No newline at end of file diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..e60b464 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,30 @@ +import json +from collections import defaultdict + +import httpx +import pytest + + +@pytest.fixture +def mock_client(): + return httpx.AsyncClient( + transport=httpx.MockTransport(invalid_handler) + ) + + +def invalid_handler(request: httpx.Request): + _content = json.loads(request.content) + _failing_ids = [_x['id'] for _x in _content if _x.get('invalid')] + if _failing_ids: + return httpx.Response( + status_code=422, json={"failingIds": _failing_ids} + ) + return httpx.Response(200, json={"diu": "OK"}) + + +def get_entity_counts_by_status_code(responses: list[httpx.Response]): + counts_by_status_code = defaultdict(int) + for response in responses: + counts_by_status_code[response.status_code] += len(json.loads(response.request.content)) + + return counts_by_status_code diff --git a/tests/test_import_batching_no_sublist.py b/tests/test_import_batching_no_sublist.py new file mode 100644 index 0000000..a3550cb --- /dev/null +++ b/tests/test_import_batching_no_sublist.py @@ -0,0 +1,208 @@ +import pytest + +from funidata_utils.request_utils.async_httpx_requests import _binary_search_enabled_post_httpx +from tests.helpers import mock_client, get_entity_counts_by_status_code + + +@pytest.mark.asyncio +async def test_recursive_import_batching_with_sublists_off_no_fails(mock_client): + test_data = [ + [ + { + "id": 2, + "person": 1, + }, + { + "id": 3, + "person": 1 + } + ], + [ + { + "id": 3, + "person": 2 + }, + { + "id": 4, + "person": 2 + } + ], + [ + { + "id": 4, + "person": 3 + }, + { + "id": 5, + "person": 3 + } + ] + ] + + results = await _binary_search_enabled_post_httpx( + path="http://localhost", + payload=test_data, + auth=None, + client=mock_client, + binary_search_depth=0, + binary_err_search_sublists=False, + binary_search_max_depth=None, + ) + assert get_entity_counts_by_status_code(results)[200] == 6 + assert get_entity_counts_by_status_code(results).get(422) is None + + +@pytest.mark.asyncio +async def test_recursive_import_batching_with_sublists_off_one_fail(mock_client): + test_data = [ + [ + { + "id": 2, + "person": 1, + "invalid": True + }, + { + "id": 3, + "person": 1 + } + ], + [ + { + "id": 3, + "person": 2 + }, + { + "id": 4, + "person": 2 + } + ], + [ + { + "id": 4, + "person": 3 + }, + { + "id": 5, + "person": 3 + } + ] + ] + + results = await _binary_search_enabled_post_httpx( + path="http://localhost", + payload=test_data, + auth=None, + client=mock_client, + binary_search_depth=0, + binary_err_search_sublists=False, + binary_search_max_depth=None, + ) + assert get_entity_counts_by_status_code(results)[200] == 4 + assert get_entity_counts_by_status_code(results)[422] == 2 + + +@pytest.mark.asyncio +async def test_recursive_import_batching_with_sublists_off_multiple_fails(mock_client): + test_data = [ + [ + { + "id": 2, + "person": 1, + "invalid": True + }, + { + "id": 3, + "person": 1, + "invalid": True, + } + ], + [ + { + "id": 3, + "person": 2 + }, + { + "id": 4, + "person": 2 + } + ], + [ + { + "id": 4, + "person": 3 + }, + { + "id": 5, + "person": 3, + "invalid": True, + } + ] + ] + + results = await _binary_search_enabled_post_httpx( + path="http://localhost", + payload=test_data, + auth=None, + client=mock_client, + binary_search_depth=0, + binary_err_search_sublists=False, + binary_search_max_depth=None, + ) + + assert get_entity_counts_by_status_code(results)[200] == 2 + assert get_entity_counts_by_status_code(results)[422] == 4 + + +@pytest.mark.asyncio +async def test_recursive_import_batching_with_sublists_off_all_fails(mock_client): + test_data = [ + [ + { + "id": 2, + "person": 1, + "invalid": True + }, + { + "id": 3, + "person": 1, + "invalid": True, + } + ], + [ + { + "id": 3, + "person": 2, + "invalid": True, + }, + { + "id": 4, + "person": 2, + "invalid": True, + } + ], + [ + { + "id": 4, + "person": 3, + "invalid": True, + }, + { + "id": 5, + "person": 3, + "invalid": True, + } + ] + ] + + results = await _binary_search_enabled_post_httpx( + path="http://localhost", + payload=test_data, + auth=None, + client=mock_client, + binary_search_depth=0, + binary_err_search_sublists=False, + binary_search_max_depth=None, + ) + + assert get_entity_counts_by_status_code(results).get(200) is None + assert get_entity_counts_by_status_code(results)[422] == 6 diff --git a/tests/test_import_batching_with_sublist.py b/tests/test_import_batching_with_sublist.py new file mode 100644 index 0000000..0c2e420 --- /dev/null +++ b/tests/test_import_batching_with_sublist.py @@ -0,0 +1,208 @@ +import pytest + +from funidata_utils.request_utils.async_httpx_requests import _binary_search_enabled_post_httpx +from tests.helpers import mock_client, get_entity_counts_by_status_code + + +@pytest.mark.asyncio +async def test_recursive_import_batching_with_sublists_on_no_fails(mock_client): + test_data = [ + [ + { + "id": 2, + "person": 1, + }, + { + "id": 3, + "person": 1 + } + ], + [ + { + "id": 3, + "person": 2 + }, + { + "id": 4, + "person": 2 + } + ], + [ + { + "id": 4, + "person": 3 + }, + { + "id": 5, + "person": 3 + } + ] + ] + + results = await _binary_search_enabled_post_httpx( + path="http://localhost", + payload=test_data, + auth=None, + client=mock_client, + binary_search_depth=0, + binary_err_search_sublists=False, + binary_search_max_depth=None, + ) + assert get_entity_counts_by_status_code(results)[200] == 6 + assert get_entity_counts_by_status_code(results).get(422) is None + + +@pytest.mark.asyncio +async def test_recursive_import_batching_with_sublists_on_one_fail(mock_client): + test_data = [ + [ + { + "id": 2, + "person": 1, + "invalid": True + }, + { + "id": 3, + "person": 1 + } + ], + [ + { + "id": 3, + "person": 2 + }, + { + "id": 4, + "person": 2 + } + ], + [ + { + "id": 4, + "person": 3 + }, + { + "id": 5, + "person": 3 + } + ] + ] + + results = await _binary_search_enabled_post_httpx( + path="http://localhost", + payload=test_data, + auth=None, + client=mock_client, + binary_search_depth=0, + binary_err_search_sublists=True, + binary_search_max_depth=None, + ) + assert get_entity_counts_by_status_code(results)[200] == 5 + assert get_entity_counts_by_status_code(results)[422] == 1 + + +@pytest.mark.asyncio +async def test_recursive_import_batching_with_sublists_on_multiple_fails(mock_client): + test_data = [ + [ + { + "id": 2, + "person": 1, + "invalid": True + }, + { + "id": 3, + "person": 1, + "invalid": True, + } + ], + [ + { + "id": 3, + "person": 2 + }, + { + "id": 4, + "person": 2 + } + ], + [ + { + "id": 4, + "person": 3 + }, + { + "id": 5, + "person": 3, + "invalid": True, + } + ] + ] + + results = await _binary_search_enabled_post_httpx( + path="http://localhost", + payload=test_data, + auth=None, + client=mock_client, + binary_search_depth=0, + binary_err_search_sublists=True, + binary_search_max_depth=None, + ) + + assert get_entity_counts_by_status_code(results)[200] == 3 + assert get_entity_counts_by_status_code(results)[422] == 3 + + +@pytest.mark.asyncio +async def test_recursive_import_batching_with_sublists_on_all_fails(mock_client): + test_data = [ + [ + { + "id": 2, + "person": 1, + "invalid": True + }, + { + "id": 3, + "person": 1, + "invalid": True, + } + ], + [ + { + "id": 3, + "person": 2, + "invalid": True, + }, + { + "id": 4, + "person": 2, + "invalid": True, + } + ], + [ + { + "id": 4, + "person": 3, + "invalid": True, + }, + { + "id": 5, + "person": 3, + "invalid": True, + } + ] + ] + + results = await _binary_search_enabled_post_httpx( + path="http://localhost", + payload=test_data, + auth=None, + client=mock_client, + binary_search_depth=0, + binary_err_search_sublists=True, + binary_search_max_depth=None, + ) + + assert get_entity_counts_by_status_code(results).get(200) is None + assert get_entity_counts_by_status_code(results)[422] == 6 diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..3441656 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,10 @@ +import pytest +from funidata_utils.utils import group_by + + +def test_group_by(): + data = [dict(id=1, type=1), dict(id=2, type=1), dict(id=3, type=2)] + + grouping = group_by(data, lambda x: x['type'] == 1) + + assert grouping[1] == [dict(id=1, type=1), dict(id=2, type=1)]