diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dfe61ff4e0c4a107e6f1e24667e271557eef2de3..d95c4620d281693da9e69143c06db82e5796ac14 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -29,9 +29,9 @@ variables: image: $CI_REGISTRY_IMAGE stages: + - setup - code_style - linting - - setup - test - deploy @@ -53,6 +53,14 @@ pylint: - make lint allow_failure: true +mypy: + tags: [ docker ] + stage: linting + script: + - pip install mypy types-PyYAML types-jsonschema types-requests types-setuptools types-lxml types-python-dateutil + - make mypy + allow_failure: true + # run unit tests unittest_py3.7: tags: [ docker ] @@ -62,7 +70,7 @@ unittest_py3.7: script: &python_test_script # Python docker has problems with tox and pip so use plain pytest here - touch ~/.pylinkahead.ini - - pip install nose pytest pytest-cov python-dateutil jsonschema>=4.4.0 + - pip install pynose pytest pytest-cov jsonschema>=4.4.0 setuptools - pip install . - python -m pytest unittests @@ -100,6 +108,22 @@ unittest_py3.11: image: python:3.11 script: *python_test_script +unittest_py3.12: + tags: [ docker ] + stage: test + needs: [ ] + image: python:3.12 + script: *python_test_script + +unittest_py3.13: + allow_failure: true + tags: [ docker ] + stage: test + needs: [ ] + image: python:3.13-rc + script: *python_test_script + + # Trigger building of server image and integration tests trigger_build: stage: deploy @@ -126,6 +150,7 @@ build-testenv: stage: setup only: - schedules + - web script: - cd unittests/docker - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY @@ -148,7 +173,7 @@ pages_prepare: &pages_prepare refs: - /^release-.*$/i script: - - echo "Deploying" + - echo "Deploying documentation" - make doc - cp -r build/doc/html public artifacts: diff --git a/CHANGELOG.md b/CHANGELOG.md index c8272314c9f98edfb184984c23accad58b77d5b1..3241b3f27d074325fd792e0d812b00bdf7154365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - Date +## [Unreleased] ## ### Added ### @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 which allows to identify property values with each other in case that one is an id and the other is an Entity with this id. Default is ``False``, so no change to the default behavior. +* Support for Python 3.12 ### Changed ### @@ -36,10 +37,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed ### +* [#104](https://gitlab.com/linkahead/linkahead-pylib/-/issues/104) Selecting + parts of a `Container` with a `slice` used to return a `list` object instead + of a `Container`, removing all useful methods of the `Container` class. This + has been fixed and using a `slice` such as `[:2]` now returns a new + `Container`. + ### Security ### ### Documentation ### +## [0.14.0] - 2024-02-20 + +### Added ### + +* `utils.merge_entities` now has a `merge_id_with_resolved_entity` keyword + which allows to identify property values with each other in case that one is + an id and the other is an Entity with this id. Default is ``False``, so no + change to the default behavior. +* `apiutils.escape_quoted_text` for escaping text in queries. + +### Changed ### + +* `cached_query()` now also caches uniqueness related exceptions. + ## [0.13.2] - 2023-12-15 ### Fixed ### diff --git a/CITATION.cff b/CITATION.cff index b9f249ed501bd1c6dd217e56d7ea47748c1032dc..cbcb570b27b7cd71f50645614222302bccc34805 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -20,6 +20,6 @@ authors: given-names: Stefan orcid: https://orcid.org/0000-0001-7214-8125 title: CaosDB - Pylib -version: 0.13.2 +version: 0.14.0 doi: 10.3390/data4020083 -date-released: 2023-10-11 +date-released: 2024-02-20 diff --git a/Makefile b/Makefile index d15c830d8e4cf6e4bc0b519b9fa5b8cb5f224043..eb767dd4053a2b232d425126358cfb0fd23ffb1c 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ doc: install: @echo "Not implemented yet, use pip for installation." -check: style lint +check: style lint mypy .PHONY: check style: @@ -43,6 +43,10 @@ lint: pylint --unsafe-load-any-extension=y -d all -e E,F src/linkahead/common .PHONY: lint +mypy: + mypy src/linkahead +.PHONY: mypy + unittest: tox -r .PHONY: unittest diff --git a/setup.py b/setup.py index 12ec4371c9169e15118d0a37f27831d22359ddff..a538e363b3f570506b6732a583de5cd139a0e391 100755 --- a/setup.py +++ b/setup.py @@ -48,8 +48,8 @@ from setuptools import find_packages, setup ISRELEASED = False MAJOR = 0 -MINOR = 13 -MICRO = 3 +MINOR = 14 +MICRO = 1 # Do not tag as pre-release until this commit # https://github.com/pypa/packaging/pull/515 # has made it into a release. Probably we should wait for pypa/packaging>=21.4 @@ -183,6 +183,7 @@ def setup_package(): package_dir={'': 'src'}, install_requires=['lxml>=4.6.3', "requests[socks]>=2.26", + "setuptools", "python-dateutil>=2.8.2", 'PyYAML>=5.4.1', 'future', diff --git a/src/caosdb/utils/escape.py b/src/caosdb/utils/escape.py new file mode 100644 index 0000000000000000000000000000000000000000..eecb8885581ec6ea9ecc7a0afb6028430b3d9622 --- /dev/null +++ b/src/caosdb/utils/escape.py @@ -0,0 +1,6 @@ + +from linkahead.utils.escape import * +from warnings import warn + +warn(("CaosDB was renamed to LinkAhead. Please import this library as `import linkahead.utils.escape`. Using the" + " old name, starting with caosdb, is deprecated."), DeprecationWarning) diff --git a/src/doc/conf.py b/src/doc/conf.py index 6604aa6eff4b2bff7b7372b97e57e0f549dd3a50..61a60d7c9e8d5c6b0959f4bba230cd483c06bc79 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -29,10 +29,10 @@ copyright = '2023, IndiScale GmbH' author = 'Daniel Hornung' # The short X.Y version -version = '0.13.3' +version = '0.14.1' # The full version, including alpha/beta/rc tags # release = '0.5.2-rc2' -release = '0.13.3-dev' +release = '0.14.1-dev' # -- General configuration --------------------------------------------------- diff --git a/src/doc/index.rst b/src/doc/index.rst index 24373d4d7c7d68be51915b25cc6201a84a6a4dc0..5139461d47067fed340459c33432a71e80108e7b 100644 --- a/src/doc/index.rst +++ b/src/doc/index.rst @@ -15,6 +15,7 @@ Welcome to PyLinkAhead's documentation! High Level API <high_level_api> Code gallery <gallery/index> API documentation <_apidoc/linkahead> + Related Projects <related_projects/index> Back to Overview <https://docs.indiscale.com/> diff --git a/src/doc/related_projects/index.rst b/src/doc/related_projects/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..fd607dfbb67a19c46f525b3f89ce5f597a711676 --- /dev/null +++ b/src/doc/related_projects/index.rst @@ -0,0 +1,25 @@ +Related Projects +++++++++++++++++ + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + :hidden: + +.. container:: projects + + For in-depth documentation for users, administrators and developers, you may want to visit the subproject-specific documentation pages for: + + :`Server <https://docs.indiscale.com/caosdb-server>`_: The Java part of the LinkAhead server. + + :`MySQL backend <https://docs.indiscale.com/caosdb-mysqlbackend>`_: The MySQL/MariaDB components of the LinkAhead server. + + :`WebUI <https://docs.indiscale.com/caosdb-webui>`_: The default web frontend for the LinkAhead server. + + :`Advanced user tools <https://docs.indiscale.com/caosdb-advanced-user-tools>`_: The advanced Python tools for LinkAhead. + + :`LinkAhead Crawler <https://docs.indiscale.com/caosdb-crawler/>`_: The crawler is the main tool for automatic data integration in LinkAhead. + + :`LinkAhead <https://docs.indiscale.com/caosdb-deploy>`_: Your all inclusive LinkAhead software package. + + :`Back to Overview <https://docs.indiscale.com/>`_: LinkAhead Documentation. diff --git a/src/linkahead/apiutils.py b/src/linkahead/apiutils.py index 39f97fcd49b88fa727102facd01a1579b5b36404..e2ed0facea84e6056b1ac877b4417ce6ad8ef504 100644 --- a/src/linkahead/apiutils.py +++ b/src/linkahead/apiutils.py @@ -215,6 +215,10 @@ def compare_entities(old_entity: Entity, new_entity: Entity, compare_referenced_ if old_entity is new_entity: return (olddiff, newdiff) + if type(old_entity) is not type(new_entity): + raise ValueError( + "Comparison of different Entity types is not supported.") + for attr in SPECIAL_ATTRIBUTES: try: oldattr = old_entity.__getattribute__(attr) @@ -501,11 +505,11 @@ def describe_diff(olddiff, newdiff, name=None, as_update=True): if len(olddiff["parents"]) > 0: description += ("Parents that are only in the old version:\n" - + ", ".join(olddiff["parents"])) + + ", ".join(olddiff["parents"]) + "\n") if len(newdiff["parents"]) > 0: description += ("Parents that are only in the new version:\n" - + ", ".join(olddiff["parents"])) + + ", ".join(olddiff["parents"]) + "\n") for prop in list(set(list(olddiff["properties"].keys()) + list(newdiff["properties"].keys()))): diff --git a/src/linkahead/cached.py b/src/linkahead/cached.py index 2eff5b1b7e0b9c3a6b3b5b461c6920a2a90f3202..b27afe0469bcaac733ece4c0be3d8d124f6305c0 100644 --- a/src/linkahead/cached.py +++ b/src/linkahead/cached.py @@ -36,6 +36,7 @@ from enum import Enum from functools import lru_cache from typing import Union +from .exceptions import EmptyUniqueQueryError, QueryNotUniqueError from .utils import get_entity from .common.models import execute_query, Entity, Container @@ -80,16 +81,22 @@ If a query phrase is given, the result must be unique. If this is not what you if count != 1: raise ValueError("You must supply exactly one argument.") + result = (None, ) if eid is not None: - return _cached_access(AccessType.EID, eid, unique=True) + result = _cached_access(AccessType.EID, eid, unique=True) if name is not None: - return _cached_access(AccessType.NAME, name, unique=True) + result = _cached_access(AccessType.NAME, name, unique=True) if path is not None: - return _cached_access(AccessType.PATH, path, unique=True) + result = _cached_access(AccessType.PATH, path, unique=True) if query is not None: - return _cached_access(AccessType.QUERY, query, unique=True) + result = _cached_access(AccessType.QUERY, query, unique=True) - raise ValueError("Not all arguments may be None.") + if result != (None, ): + if isinstance(result, (QueryNotUniqueError, EmptyUniqueQueryError)): + raise result + return result + + raise RuntimeError("This line should never be reached.") def cached_query(query_string) -> Container: @@ -98,7 +105,10 @@ def cached_query(query_string) -> Container: All additional arguments are at their default values. """ - return _cached_access(AccessType.QUERY, query_string, unique=False) + result = _cached_access(AccessType.QUERY, query_string, unique=False) + if isinstance(result, (QueryNotUniqueError, EmptyUniqueQueryError)): + raise result + return result @lru_cache(maxsize=DEFAULT_SIZE) @@ -111,14 +121,17 @@ def _cached_access(kind: AccessType, value: Union[str, int], unique=True): if value in _DUMMY_CACHE: return _DUMMY_CACHE[value] - if kind == AccessType.QUERY: - return execute_query(value, unique=unique) - if kind == AccessType.NAME: - return get_entity.get_entity_by_name(value) - if kind == AccessType.EID: - return get_entity.get_entity_by_id(value) - if kind == AccessType.PATH: - return get_entity.get_entity_by_path(value) + try: + if kind == AccessType.QUERY: + return execute_query(value, unique=unique) + if kind == AccessType.NAME: + return get_entity.get_entity_by_name(value) + if kind == AccessType.EID: + return get_entity.get_entity_by_id(value) + if kind == AccessType.PATH: + return get_entity.get_entity_by_path(value) + except (QueryNotUniqueError, EmptyUniqueQueryError) as exc: + return exc raise ValueError(f"Unknown AccessType: {kind}") diff --git a/src/linkahead/common/models.py b/src/linkahead/common/models.py index d7701ac1292cbade9bbbe9ec0d9f9c35fe75288d..4a8f4ce33dc0d07e226fd27b7ffd50c9e61f7c69 100644 --- a/src/linkahead/common/models.py +++ b/src/linkahead/common/models.py @@ -4,9 +4,10 @@ # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen -# Copyright (C) 2020-2023 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2020-2024 IndiScale GmbH <info@indiscale.com> # Copyright (C) 2020-2023 Florian Spreckelsen <f.spreckelsen@indiscale.com> # Copyright (C) 2020-2022 Timm Fitschen <t.fitschen@indiscale.com> +# Copyright (C) 2024 Joscha Schmiedt <joscha@schmiedt.dev> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -63,8 +64,7 @@ from ..exceptions import (AmbiguousEntityError, AuthorizationError, UniqueNamesError, UnqualifiedParentsError, UnqualifiedPropertiesError) from .datatype import (BOOLEAN, DATETIME, DOUBLE, INTEGER, TEXT, - get_list_datatype, - is_list_datatype, is_reference) + get_list_datatype, is_list_datatype, is_reference) from .state import State from .timezone import TimeZone from .utils import uuid, xml2str @@ -82,7 +82,7 @@ NONE = "NONE" SPECIAL_ATTRIBUTES = ["name", "role", "datatype", "description", - "id", "path", "checksum", "size"] + "id", "path", "checksum", "size", "value"] class Entity: @@ -156,7 +156,7 @@ class Entity: # Copy special attributes: # TODO: this might rise an exception when copying # special file attributes like checksum and size. - for attribute in SPECIAL_ATTRIBUTES + ["value"]: + for attribute in SPECIAL_ATTRIBUTES: val = getattr(self, attribute) if val is not None: setattr(new, attribute, val) @@ -1537,7 +1537,12 @@ def _parse_value(datatype, value): return float(value) if datatype == INTEGER: - return int(str(value)) + if isinstance(value, int): + return value + elif isinstance(value, float) and value.is_integer(): + return int(value) + else: + return int(str(value)) if datatype == BOOLEAN: if str(value).lower() == "true": @@ -3069,6 +3074,14 @@ class Container(list): def __repr__(self): return xml2str(self.to_xml()) + def __getitem__(self, key): + self_as_list_slice = super().__getitem__(key) + if isinstance(self_as_list_slice, list): + # Construct new Container from list slice + return Container().extend(self_as_list_slice) + else: + return self_as_list_slice + @staticmethod def from_xml(xml_str): """Creates a Container from the given xml string. @@ -4588,8 +4601,9 @@ def execute_query(q, unique=False, raise_exception_on_error=True, cache=True, Whether an exception should be raised when there are errors in the resulting entities. Defaults to True. cache : bool - Whether to use the query server-side cache (equivalent to adding a - "cache" flag). Defaults to True. + Whether to use the server's query cache (equivalent to adding a + "cache" flag) to the Query object. Defaults to True. Not to be + confused with the ``cached`` module. flags : dict of str Flags to be added to the request. page_length : int @@ -4637,6 +4651,8 @@ class Info(): def __init__(self): self.messages = Messages() + self.user_info = None + self.time_zone = None self.sync() def sync(self): diff --git a/src/linkahead/utils/escape.py b/src/linkahead/utils/escape.py new file mode 100644 index 0000000000000000000000000000000000000000..d20a07acfdc9f9f06b31176e08dee92fcb1a19df --- /dev/null +++ b/src/linkahead/utils/escape.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# +# This file is a part of the LinkAhead Project. +# +# Copyright (C) 2024 Henrik tom Wörden <h.tomwoerden@indiscale.com> +# Copyright (C) 2024 IndiScale GmbH <info@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# + +import warnings + + +def escape_squoted_text(text: str) -> str: + r"""Return an escaped version of the argument. + + The characters ``\``, ``*`` and ``'`` need to be escaped if used in single quoted + expressions in the query language. + + This function returns the given string where the characters ``\``, ``'`` and ``*`` are + escaped by a ``\`` (backslash character). + + Parameters + ---------- + text : str + The text to be escaped. + + Returns + ------- + out : str + The escaped text. + """ + return text.replace("\\", r"\\").replace("'", r"\'").replace("*", r"\*") + + +def escape_dquoted_text(text: str) -> str: + r"""Return an escaped version of the argument. + + The characters ``\``, ``*`` and ``"`` need to be escaped if used in double quoted + expressions in the query language. + + This function returns the given string where the characters ``\``, ``"`` and ``*`` are + escaped by a ``\`` (backslash character). + + Parameters + ---------- + text : str + The text to be escaped. + + Returns + ------- + out : str + The escaped text. + """ + return text.replace("\\", r"\\").replace('"', r"\"").replace("*", r"\*") + + +def escape_quoted_text(text: str) -> str: + """ + Please use escape_squoted_text or escape_dquoted_text instead of this function. + """ + warnings.warn("Please use escape_squoted_text or escape_dquoted_text", DeprecationWarning) + return escape_squoted_text(text) diff --git a/src/linkahead/utils/get_entity.py b/src/linkahead/utils/get_entity.py index ea9f3228bfc32f223979846623fccdec45752e5d..282f7c86e10571d0e0d62b93da7f61bba5205cba 100644 --- a/src/linkahead/utils/get_entity.py +++ b/src/linkahead/utils/get_entity.py @@ -22,7 +22,9 @@ """Convenience functions to retrieve a specific entity.""" from typing import Union -from ..common.models import execute_query, Entity + +from ..common.models import Entity, execute_query +from .escape import escape_squoted_text def get_entity_by_name(name: str) -> Entity: @@ -30,6 +32,7 @@ def get_entity_by_name(name: str) -> Entity: Submits the query "FIND ENTITY WITH name='{name}'". """ + name = escape_squoted_text(name) return execute_query(f"FIND ENTITY WITH name='{name}'", unique=True) diff --git a/tox.ini b/tox.ini index 5008eb42b1b99b04d22a1c86478edbd99ef95e69..8fc8e63e2a747d8aae6246e11c8793c306e48e2e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,13 @@ [tox] -envlist=py37, py38, py39, py310, py311 +envlist=py37, py38, py39, py310, py311, py312, py313 skip_missing_interpreters = true [testenv] deps = . - nose + pynose pytest pytest-cov + mypy jsonschema>=4.4.0 commands=py.test --cov=linkahead -vv {posargs} @@ -17,3 +18,4 @@ max-line-length=100 testpaths = unittests xfail_strict = True addopts = -x -vv --cov=linkahead +pythonpath = src diff --git a/unittests/docker/Dockerfile b/unittests/docker/Dockerfile index 9b848cf69c829408f3f3edd599323b6b0321e041..51a9006ff59aad81e3f2fc09b5d783518a07f06e 100644 --- a/unittests/docker/Dockerfile +++ b/unittests/docker/Dockerfile @@ -12,4 +12,4 @@ ARG COMMIT="dev" # TODO Rename to linkahead RUN git clone -b dev https://gitlab.indiscale.com/caosdb/src/caosdb-pylib.git linkahead-pylib && \ cd linkahead-pylib && git checkout $COMMIT && pip3 install . -RUN pip3 install recommonmark sphinx-rtd-theme +RUN pip3 install recommonmark sphinx-rtd-theme mypy diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index 549312c367eea90eac79a9bbb4898cde76f8e8ac..4705f19a1bdfbc4358790f787f2dce9ea97fee48 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -26,13 +26,12 @@ # A. Schlemmer, 02/2018 -import pytest import linkahead as db import linkahead.apiutils -from linkahead.apiutils import (apply_to_ids, compare_entities, create_id_query, - empty_diff, EntityMergeConflictError, - resolve_reference, merge_entities) - +import pytest +from linkahead.apiutils import (EntityMergeConflictError, apply_to_ids, + compare_entities, create_id_query, empty_diff, + merge_entities, resolve_reference) from linkahead.common.models import SPECIAL_ATTRIBUTES @@ -104,6 +103,8 @@ def test_compare_entities(): r1.add_parent("lopp") r1.add_property("test", value=2) r2.add_property("test", value=2) + r1.add_property("testi", importance=linkahead.SUGGESTED, value=2) + r2.add_property("testi", importance=linkahead.RECOMMENDED, value=2) r1.add_property("tests", value=3) r2.add_property("tests", value=45) r1.add_property("tester", value=3) @@ -115,8 +116,8 @@ def test_compare_entities(): assert len(diff_r1["parents"]) == 1 assert len(diff_r2["parents"]) == 0 - assert len(diff_r1["properties"]) == 3 - assert len(diff_r2["properties"]) == 3 + assert len(diff_r1["properties"]) == 4 + assert len(diff_r2["properties"]) == 4 assert "test" not in diff_r1["properties"] assert "test" not in diff_r2["properties"] @@ -124,6 +125,9 @@ def test_compare_entities(): assert "tests" in diff_r1["properties"] assert "tests" in diff_r2["properties"] + assert "testi" in diff_r1["properties"] + assert "testi" in diff_r2["properties"] + assert "tester" in diff_r1["properties"] assert "tester" in diff_r2["properties"] @@ -212,7 +216,6 @@ def test_compare_special_properties(): assert len(diff_r2["properties"]) == 0 -@pytest.mark.xfail def test_compare_properties(): p1 = db.Property() p2 = db.Property() @@ -223,21 +226,12 @@ def test_compare_properties(): assert len(diff_r1["properties"]) == 0 assert len(diff_r2["properties"]) == 0 - p1.importance = "SUGGESTED" diff_r1, diff_r2 = compare_entities(p1, p2) assert len(diff_r1["parents"]) == 0 assert len(diff_r2["parents"]) == 0 assert len(diff_r1["properties"]) == 0 assert len(diff_r2["properties"]) == 0 - assert "importance" in diff_r1 - assert diff_r1["importance"] == "SUGGESTED" - - # TODO: I'm not sure why it is not like this: - # assert diff_r2["importance"] is None - # ... but: - assert "importance" not in diff_r2 - p2.importance = "SUGGESTED" p1.value = 42 p2.value = 4 diff --git a/unittests/test_container.py b/unittests/test_container.py index 4cd8fefcaefee9fe6fdc5857805353227b493dfb..c3a60140d43383c81f03c38c9dd5cc7779bc77ba 100644 --- a/unittests/test_container.py +++ b/unittests/test_container.py @@ -5,7 +5,8 @@ # This file is a part of the LinkAhead Project. # # Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> -# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2024 Joscha Schmiedt <joscha@schmiedt.dev> +# Copyright (C) 2020-2024 IndiScale GmbH <info@indiscale.com> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -178,3 +179,23 @@ def test_container_deletion_with_references(): assert len(deps14a) == 1 and deps14a.pop() == -1 assert len(deps14b) == 1 and deps14b.pop() == -1 assert len(deps15) == 1 and deps15.pop() == -1 + + +def test_container_slicing(): + cont = db.Container() + cont.extend([db.Record(name=f"TestRec{ii+1}") for ii in range(5)]) + assert isinstance(cont, db.common.models.Container) + container_slice = cont[:2] + assert isinstance(container_slice, db.common.models.Container), \ + f"Container slice should be Container, was {type(container_slice)}" + for element in container_slice: + assert isinstance(element, db.Record), \ + f"element in slice was not Record, but {type(element)}" + assert len(container_slice) == 2 + assert cont[-1].name == "TestRec5" + + with pytest.raises(TypeError): + cont["stringkey"] + + with pytest.raises(TypeError): + cont[[0, 2, 3]] diff --git a/unittests/test_utils.py b/unittests/test_utils.py index 3d8e2896247f66c98f1461c1a1e91baca5f01cb6..e7495a91d5a0d525278fa608777e6c697ac54e99 100644 --- a/unittests/test_utils.py +++ b/unittests/test_utils.py @@ -23,8 +23,10 @@ # """Tests for linkahead.common.utils.""" from __future__ import unicode_literals -from lxml.etree import Element + from linkahead.common.utils import xml2str +from linkahead.utils.escape import (escape_dquoted_text, escape_squoted_text) +from lxml.etree import Element def test_xml2str(): @@ -32,3 +34,12 @@ def test_xml2str(): element = Element(name) serialized = xml2str(element) assert serialized == "<Björn/>\n" + + +def test_escape_quoted_text(): + assert escape_squoted_text("bla") == "bla" + assert escape_squoted_text(r"bl\a") == r"bl\\a" + assert escape_squoted_text("bl*a") == r"bl\*a" + assert escape_squoted_text(r"bl*ab\\lab\*labla") == r"bl\*ab\\\\lab\\\*labla" + assert escape_squoted_text("bl'a") == r"bl\'a" + assert escape_dquoted_text('bl"a') == r'bl\"a'