diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dfe61ff4e0c4a107e6f1e24667e271557eef2de3..1ce007dc228105849d89d4fc720b9a8bf729ee1b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -148,7 +148,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 f989ba0266a4fda67ffb71bc9e900c21a20484ed..f331067707f361da9430a24cce58d18808e4ac02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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). +## [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/setup.py b/setup.py index 55e6ee154e2906494bece48c2f741e53e78aa2fe..a77b1095e2c7e3f678a85f7aff49d0a213bb9381 100755 --- a/setup.py +++ b/setup.py @@ -48,8 +48,8 @@ from setuptools import find_packages, setup ISRELEASED = True MAJOR = 0 -MINOR = 13 -MICRO = 2 +MINOR = 14 +MICRO = 0 # 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 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 84bdc6eac88b345e2b0dd04c5d1f9c413362746b..771a1f048f79b779526c56b2fd56712761021757 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.2' +version = '0.14.0' # The full version, including alpha/beta/rc tags # release = '0.5.2-rc2' -release = '0.13.2' +release = '0.14.0' # -- 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 597342e38a961c628edd84dd8dff37471ef2570b..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) @@ -354,7 +358,7 @@ def empty_diff(old_entity: Entity, new_entity: Entity, compare_referenced_record def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_empty_diffs=True, - force=False): + force=False, merge_id_with_resolved_entity: bool = False): """Merge entity_b into entity_a such that they have the same parents and properties. datatype, unit, value, name and description will only be changed in entity_a @@ -372,16 +376,22 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp Parameters ---------- entity_a, entity_b : Entity - The entities to be merged. entity_b will be merged into entity_a in place + The entities to be merged. entity_b will be merged into entity_a in place merge_references_with_empty_diffs : bool, optional - Whether the merge is performed if entity_a and entity_b both reference - record(s) that may be different Python objects but have empty diffs. If - set to `False` a merge conflict will be raised in this case - instead. Default is True. + Whether the merge is performed if entity_a and entity_b both reference + record(s) that may be different Python objects but have empty diffs. If + set to `False` a merge conflict will be raised in this case + instead. Default is True. force : bool, optional - If True, in case `entity_a` and `entity_b` have the same properties, the - values of `entity_a` are replaced by those of `entity_b` in the merge. - If `False`, an EntityMergeConflictError is raised instead. Default is False. + If True, in case `entity_a` and `entity_b` have the same properties, the + values of `entity_a` are replaced by those of `entity_b` in the + merge. If `False`, an EntityMergeConflictError is raised + instead. Default is False. + merge_id_with_resolved_entity : bool, optional + If true, the values of two reference properties will be considered the + same if one is an integer id and the other is a db.Entity with this + id. I.e., a value 123 is identified with a value ``<Record + id=123/>``. Default is False. Returns ------- @@ -427,13 +437,31 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp setattr(entity_a.get_property(key), attribute, diff_r2["properties"][key][attribute]) else: - raise EntityMergeConflictError( - f"Entity a ({entity_a.id}, {entity_a.name}) " - f"has a Property '{key}' with {attribute}=" - f"{diff_r2['properties'][key][attribute]}\n" - f"Entity b ({entity_b.id}, {entity_b.name}) " - f"has a Property '{key}' with {attribute}=" - f"{diff_r1['properties'][key][attribute]}") + raise_error = True + if merge_id_with_resolved_entity is True and attribute == "value": + # Do a special check for the case of an id value on the + # one hand, and a resolved entity on the other side. + this = entity_a.get_property(key).value + that = entity_b.get_property(key).value + same = False + if isinstance(this, list) and isinstance(that, list): + if len(this) == len(that): + same = all([_same_id_as_resolved_entity(a, b) + for a, b in zip(this, that)]) + else: + same = _same_id_as_resolved_entity(this, that) + if same is True: + setattr(entity_a.get_property(key), attribute, + diff_r2["properties"][key][attribute]) + raise_error = False + if raise_error is True: + raise EntityMergeConflictError( + f"Entity a ({entity_a.id}, {entity_a.name}) " + f"has a Property '{key}' with {attribute}=" + f"{diff_r2['properties'][key][attribute]}\n" + f"Entity b ({entity_b.id}, {entity_b.name}) " + f"has a Property '{key}' with {attribute}=" + f"{diff_r1['properties'][key][attribute]}") else: # TODO: This is a temporary FIX for # https://gitlab.indiscale.com/caosdb/src/caosdb-pylib/-/issues/105 @@ -477,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()))): @@ -586,3 +614,16 @@ def create_flat_list(ent_list: List[Entity], flat: List[Entity]): flat.append(p.value) # TODO: move inside if block? create_flat_list([p.value], flat) + + +def _same_id_as_resolved_entity(this, that): + """Checks whether ``this`` and ``that`` either are the same or whether one + is an id and the other is a db.Entity with this id. + + """ + if isinstance(this, Entity) and not isinstance(that, Entity): + # this is an Entity with an id, that is not + return this.id is not None and this.id == that + if not isinstance(this, Entity) and isinstance(that, Entity): + return that.id is not None and that.id == this + return this == that 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 38c1349067fce68dc3dc0311dc621bd0e383d4b0..ea537ffe8c44a7a7fb79c2d4080b63f9b3da2284 100644 --- a/src/linkahead/common/models.py +++ b/src/linkahead/common/models.py @@ -63,8 +63,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 +81,7 @@ NONE = "NONE" SPECIAL_ATTRIBUTES = ["name", "role", "datatype", "description", - "id", "path", "checksum", "size"] + "id", "path", "checksum", "size", "value"] class Entity: @@ -155,7 +154,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) @@ -1503,7 +1502,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": @@ -4510,8 +4514,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 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/unittests/test_apiutils.py b/unittests/test_apiutils.py index b9a02926803c1e7b8134cde904ea2021d0281ff4..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 @@ -588,3 +582,48 @@ def test_merge_missing_list_datatype_82(): with pytest.raises(TypeError) as te: merge_entities(recA, recB_without_DT, force=True) assert "Invalid datatype: List valued properties" in str(te.value) + + +def test_merge_id_with_resolved_entity(): + + rtname = "TestRT" + ref_id = 123 + ref_rec = db.Record(id=ref_id).add_parent(name=rtname) + + # recA has the resolved referenced record as value, recB its id. Otherwise, + # they are identical. + recA = db.Record().add_property(name=rtname, value=ref_rec) + recB = db.Record().add_property(name=rtname, value=ref_id) + + # default is strict: raise error since values are different + with pytest.raises(EntityMergeConflictError): + merge_entities(recA, recB) + + # Overwrite from right to left in both cases + merge_entities(recA, recB, merge_id_with_resolved_entity=True) + assert recA.get_property(rtname).value == ref_id + assert recA.get_property(rtname).value == recB.get_property(rtname).value + + recA = db.Record().add_property(name=rtname, value=ref_rec) + merge_entities(recB, recA, merge_id_with_resolved_entity=True) + assert recB.get_property(rtname).value == ref_rec + assert recA.get_property(rtname).value == recB.get_property(rtname).value + + # id mismatches + recB = db.Record().add_property(name=rtname, value=ref_id*2) + with pytest.raises(EntityMergeConflictError): + merge_entities(recA, recB, merge_id_with_resolved_entity=True) + + other_rec = db.Record(id=None).add_parent(name=rtname) + recA = db.Record().add_property(name=rtname, value=other_rec) + recB = db.Record().add_property(name=rtname, value=ref_id) + with pytest.raises(EntityMergeConflictError): + merge_entities(recA, recB, merge_id_with_resolved_entity=True) + + # also works in lists: + recA = db.Record().add_property( + name=rtname, datatype=db.LIST(rtname), value=[ref_rec, ref_id*2]) + recB = db.Record().add_property(name=rtname, datatype=db.LIST(rtname), value=[ref_id, ref_id*2]) + merge_entities(recA, recB, merge_id_with_resolved_entity=True) + assert recA.get_property(rtname).value == [ref_id, ref_id*2] + assert recA.get_property(rtname).value == recB.get_property(rtname).value 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'