diff --git a/CHANGELOG.md b/CHANGELOG.md index 6969f647584c34742594b01228fbd17160aeb5e3..958933ba413af1b526e49b0fcacdaf2ffd7cf344 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] ## +## [0.15.0] - 2024-07-09 ## ### Added ### @@ -15,10 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Entity.update_acl` now supports optional `**kwargs` that are passed to the `Entity.update` method that is called internally, thus allowing, e.g., updating the ACL despite possible naming collisions with `unique=False`. +* a `role` argument for `get_entity_by_name` and `get_entity_by_id` ### Changed ### -### Deprecated ### +* Using environment variable PYLINKAHEADINI instead of PYCAOSDBINI. ### Removed ### @@ -34,10 +35,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [#120](https://gitlab.com/linkahead/linkahead-pylib/-/issues/120) Unwanted subproperties in reference properties. -### Security ### - ### Documentation ### +* Added documentation and a tutorial example for the usage of the `page_length` + argument of `execute_query`. + ## [0.14.0] - 2024-02-20 ### Added ### diff --git a/CITATION.cff b/CITATION.cff index cbcb570b27b7cd71f50645614222302bccc34805..148cccb1804f7ae254224074dfef408e014f5438 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.14.0 +version: 0.15.0 doi: 10.3390/data4020083 -date-released: 2024-02-20 +date-released: 2024-07-09 diff --git a/examples/pylinkahead.ini b/examples/pylinkahead.ini index f37e24e0e5b754ec58a07b034ba2755096f0b441..84d1eb8526201c817d6614e7eb74f35a932c5d78 100644 --- a/examples/pylinkahead.ini +++ b/examples/pylinkahead.ini @@ -1,7 +1,7 @@ # To be found be the caosdb package, the INI file must be located either in # - $CWD/pylinkahead.ini # - $HOME/.pylinkahead.ini -# - the location given in the env variable PYCAOSDBINI +# - the location given in the env variable PYLINKAHEADINI [Connection] # URL of the CaosDB server diff --git a/setup.py b/setup.py index ee2a5fb6fd7212acfc9ce9bc732fc9f2d4f345b4..1a8a754219ddf84c0b9e088a13fd0283fa63a00f 100755 --- a/setup.py +++ b/setup.py @@ -46,10 +46,10 @@ from setuptools import find_packages, setup # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ######################################################################## -ISRELEASED = False +ISRELEASED = True MAJOR = 0 -MINOR = 14 -MICRO = 1 +MINOR = 15 +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/doc/conf.py b/src/doc/conf.py index 61a60d7c9e8d5c6b0959f4bba230cd483c06bc79..4a528d9e287daeeacd50e94cfba4e479b0430212 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.14.1' +version = '0.15.0' # The full version, including alpha/beta/rc tags # release = '0.5.2-rc2' -release = '0.14.1-dev' +release = '0.15.0' # -- General configuration --------------------------------------------------- diff --git a/src/doc/configuration.md b/src/doc/configuration.md index 54ae251b9db9ef000545e701406b979aa58043f8..427551db4e1e97d7ca5f9820df6d5916e3496020 100644 --- a/src/doc/configuration.md +++ b/src/doc/configuration.md @@ -1,6 +1,6 @@ # Configuration of PyLinkAhead # The behavior of PyLinkAhead is defined via a configuration that is provided using configuration files. -PyLinkAhead tries to read from the inifile specified in the environment variable `PYCAOSDBINI` or +PyLinkAhead tries to read from the inifile specified in the environment variable `PYLINKAHEADINI` or alternatively in `~/.pylinkahead.ini` upon import. After that, the ini file `pylinkahead.ini` in the current working directory will be read additionally, if it exists. diff --git a/src/doc/tutorials/index.rst b/src/doc/tutorials/index.rst index 706e26c2b1b4876c29d43c2bddd9a5fe357a003d..e745482ace189e10a042975869cae6310f6ad703 100644 --- a/src/doc/tutorials/index.rst +++ b/src/doc/tutorials/index.rst @@ -15,6 +15,7 @@ advanced usage of the Python client. Data-Insertion errors Entity-Getters + paginated_queries caching data-model-interface complex_data_models diff --git a/src/doc/tutorials/paginated_queries.rst b/src/doc/tutorials/paginated_queries.rst new file mode 100644 index 0000000000000000000000000000000000000000..c250223f46405caa9d289ce4d8774daf06fdf366 --- /dev/null +++ b/src/doc/tutorials/paginated_queries.rst @@ -0,0 +1,63 @@ +Query pagination +================ + +When retrieving many entities, you may not want to retrieve all at once, e.g., +for performance reasons or to prevent connection timeouts, but rather in a +chunked way. For that purpose, there is the ``page_length`` parameter in the +:py:meth:`~linkahead.common.models.execute_query` function. If this is set to a +non-zero integer, the behavior of the function changes in that it returns a +Python `generator <https://docs.python.org/3/glossary.html#term-generator>`_ +which can be used, e.g., in loops or in list comprehension. The generator yields +a :py:class:`~linkahead.common.models.Container` containing the next +``page_length`` many entities from the query result. + +The following example illustrates this on the demo server. + +.. code-block:: python + + import linkahead as db + + # 10 at the time of writing of this example + print(db.execute_query("FIND MusicalInstrument")) + + # Retrieve in pages of length 5 and iterate over the pages + for page in db.execute_query("FIND MusicalInstrument", page_length=5): + # each page is a container + print(type(page)) + # exactly page_length=5 for the first N-1 pages, + # and possibly less for the last page + print(len(page)) + # the items on each page are subclasses of Entity + print(type(page[0])) + # The id of the first entity on the page is different for all pages + print(page[0].id) + + # You can use this in a list comprehension to fill a container + container_paginated = db.Container().extend( + [ent for page in db.execute_query("FIND MusicalInstrument", page_length=5) for ent in page] + ) + # The result is the same as in the unpaginated case, but the + # following can cause connection timeouts in case of very large + # retrievals + container_at_once = db.execute_query("FIND MusicalInstrument") + for ent1, ent2 in zip(container_paginated, container_at_once): + print(ent1.id == ent2.id) # always true + +As you can see, you can iterate over a paginated query and then access the +entities on each page during the iteration. + +.. note:: + + The ``page_length`` keyword is ignored for ``COUNT`` queries where + :py:meth:`~linkahead.common.models.execute_query` always returns the integer + result and in case of ``unique=True`` where always exactly one + :py:class:`~linkahead.common.models.Entity` is returned. + + +.. warning:: + + Be careful when combining query pagination with insert, update, or delete + operations. If your database changes while iterating over a paginated query, + the client will raise a + :py:exc:`~linkahead.exceptions.PagingConsistencyError` since the server + can't guarantee that the query results haven't changed in the meantime. diff --git a/src/linkahead/__init__.py b/src/linkahead/__init__.py index 3a8c5ba39c88deaa5dc945135e3828945fd39d58..cd54f8f4e05326579521fbbf226f027d32fa616e 100644 --- a/src/linkahead/__init__.py +++ b/src/linkahead/__init__.py @@ -24,7 +24,7 @@ """LinkAhead Python bindings. -Tries to read from the inifile specified in the environment variable `PYCAOSDBINI` or +Tries to read from the inifile specified in the environment variable `PYLINKAHEADINI` or alternatively in `~/.pylinkahead.ini` upon import. After that, the ini file `pylinkahead.ini` in the current working directory will be read additionally, if it exists. diff --git a/src/linkahead/common/models.py b/src/linkahead/common/models.py index 06f09057be50d76e1e73a95375030f13aaaced86..39b912fb0af211730b256023e4ad0b9b7fff1759 100644 --- a/src/linkahead/common/models.py +++ b/src/linkahead/common/models.py @@ -282,7 +282,7 @@ class Entity: @id.setter def id(self, new_id) -> None: if new_id is not None: - self.__id: Optional[int] = entity_id(new_id) + self.__id: Union[int, str, None] = entity_id(new_id) else: self.__id = None @@ -2018,8 +2018,6 @@ class Property(Entity): Parameters ---------- - Parameters - ---------- parent : Entity or int or str or None The parent entity, either specified by the Entity object itself, or its id or its name. Default is None. @@ -3215,9 +3213,15 @@ class Container(list): """Get an xml tree representing this Container or append all entities to the given xml element. - @param add_to_element=None: optional element to which all entities of this container is to - be appended. - @return xml element + Parameters + ---------- + add_to_element : etree._Element, optional + optional element to which all entities of this container is to + be appended. Default is None + + Returns + ------- + xml_element : etree._Element """ tmpid = 0 @@ -4153,13 +4157,21 @@ class Container(list): warnings as errors. This prevents the server from inserting this entity if any warning occurs. - @param strict=False: Flag for strict mode. - @param sync=True: synchronize this container with the response from the server. Otherwise, - this method returns a new container with the inserted entities and leaves - this container untouched. - @param unique=True: Flag for unique mode. If set to True, the server will check if the name - of the entity is unique. If not, the server will return an error. - @param flags=None: Additional flags for the server. + Parameters + ---------- + strict : bool, optional + Flag for strict mode. Default is False. + sync : bool, optional + synchronize this container with the response from the + server. Otherwise, this method returns a new container with the + inserted entities and leaves this container untouched. Default is + True. + unique : bool, optional + Flag for unique mode. If set to True, the server will check if the + name of the entity is unique. If not, the server will return an + error. Default is True. + flags : dict, optional + Additional flags for the server. Default is None. """ @@ -5047,8 +5059,8 @@ def execute_query( Otherwise, paging is disabled, as well as for count queries and when unique is True. Defaults to None. - Raises: - ------- + Raises + ------ PagingConsistencyError If the database state changed between paged requests. @@ -5110,6 +5122,17 @@ class DropOffBox(list): class UserInfo(): + """User information from a server response. + + Attributes + ---------- + name : str + Username + realm : str + Realm in which this user lives, e.g., CaosDB or LDAP. + roles : list[str] + List of roles assigned to this user. + """ def __init__(self, xml: etree._Element): self.roles = [role.text for role in xml.findall("Roles/Role")] @@ -5118,6 +5141,21 @@ class UserInfo(): class Info(): + """Info about the LinkAhead instance that you are connected to. It has a + simple string representation in the form of "Connected to a LinkAhead with N + Records". + + Attributes + ---------- + messages : Messages + Collection of messages that the server's ``Info`` response contained. + user_info : UserInfo + Information about the user that is connected to the server, such as + name, realm or roles. + time_zone : TimeZone + The timezone information returned by the server. + + """ def __init__(self): self.messages = Messages() @@ -5126,6 +5164,7 @@ class Info(): self.sync() def sync(self): + """Retrieve server information from the server's ``Info`` response.""" c = get_connection() try: http_response = c.retrieve(["Info"]) diff --git a/src/linkahead/configuration.py b/src/linkahead/configuration.py index b020467c8c53e26d464a6a2fb473cc912b0e0612..f57289d7dcb6d7ab062024dc697dbda557670d7a 100644 --- a/src/linkahead/configuration.py +++ b/src/linkahead/configuration.py @@ -102,7 +102,7 @@ def validate_yaml_schema(valobj: dict[str, dict[str, Union[int, str, bool]]]): def _read_config_files() -> list[str]: """Read config files from different paths. - Read the config from either ``$PYCAOSDBINI`` or home directory (``~/.pylinkahead.ini``), and + Read the config from either ``$PYLINKAHEADINI`` or home directory (``~/.pylinkahead.ini``), and additionally adds config from a config file in the current working directory (``pylinkahead.ini``). If deprecated names are used (starting with 'pycaosdb'), those used in addition but the files @@ -131,15 +131,18 @@ def _read_config_files() -> list[str]: warnings.warn("\n\nYou have a config file with the old naming scheme (pycaosdb.ini). " f"Please use the new version and rename\n" f" {ini_cwd_caosdb}\nto\n {ini_cwd}", DeprecationWarning) + if "PYCAOSDBINI" in environ: + warnings.warn("\n\nYou have an environment variable PYCAOSDBINI. " + "Please rename it to PYLINKAHEADINI.") # End: LinkAhead rename block ################################################## - if "PYCAOSDBINI" in environ: - if not isfile(expanduser(environ["PYCAOSDBINI"])): + if "PYLINKAHEADINI" in environ: + if not isfile(expanduser(environ["PYLINKAHEADINI"])): raise RuntimeError( - f"No configuration file found at\n{expanduser(environ['PYCAOSDBINI'])}" - "\nwhich was given via the environment variable PYCAOSDBINI" + f"No configuration file found at\n{expanduser(environ['PYLINKAHEADINI'])}" + "\nwhich was given via the environment variable PYLINKAHEADINI" ) - return_var.extend(configure(expanduser(environ["PYCAOSDBINI"]))) + return_var.extend(configure(expanduser(environ["PYLINKAHEADINI"]))) else: if isfile(ini_user_caosdb): return_var.extend(configure(ini_user_caosdb)) diff --git a/src/linkahead/utils/get_entity.py b/src/linkahead/utils/get_entity.py index ce74089784525b1a38d9c5c5bbf3193a9ba94575..f84dc107e275390e53c6127834f53e5e5c6521cd 100644 --- a/src/linkahead/utils/get_entity.py +++ b/src/linkahead/utils/get_entity.py @@ -21,29 +21,33 @@ """Convenience functions to retrieve a specific entity.""" -from typing import Union +from typing import Union, Optional from ..common.models import Entity, execute_query from .escape import escape_squoted_text -def get_entity_by_name(name: str) -> Entity: +def get_entity_by_name(name: str, role: Optional[str] = None) -> Entity: """Return the result of a unique query that uses the name to find the correct entity. Submits the query "FIND ENTITY WITH name='{name}'". """ name = escape_squoted_text(name) + if role is None: + role = "ENTITY" # type hint can be ignored, it's a unique query, so never Container or int - return execute_query(f"FIND ENTITY WITH name='{name}'", unique=True) # type: ignore + return execute_query(f"FIND {role} WITH name='{name}'", unique=True) # type: ignore -def get_entity_by_id(eid: Union[str, int]) -> Entity: +def get_entity_by_id(eid: Union[str, int], role: Optional[str] = None) -> Entity: """Return the result of a unique query that uses the id to find the correct entity. Submits the query "FIND ENTITY WITH id='{eid}'". """ + if role is None: + role = "ENTITY" # type hint can be ignored, it's a unique query - return execute_query(f"FIND ENTITY WITH id='{eid}'", unique=True) # type: ignore + return execute_query(f"FIND {role} WITH id='{eid}'", unique=True) # type: ignore def get_entity_by_path(path: str) -> Entity: diff --git a/unittests/test_configuration.py b/unittests/test_configuration.py index 40506e878b18473587da8b694d9381c15bdbd860..95bc906c6c044c51548aa864326cc93f29a6042a 100644 --- a/unittests/test_configuration.py +++ b/unittests/test_configuration.py @@ -45,24 +45,24 @@ def temp_ini_files(): remove("pylinkahead.ini") if created_temp_ini_home: remove(expanduser("~/.pylinkahead.ini")) - environ["PYCAOSDBINI"] = "~/.pylinkahead.ini" + environ["PYLINKAHEADINI"] = "~/.pylinkahead.ini" def test_config_ini_via_envvar(temp_ini_files): with raises(KeyError): - environ["PYCAOSDBINI"] + environ["PYLINKAHEADINI"] - environ["PYCAOSDBINI"] = "bla bla" - assert environ["PYCAOSDBINI"] == "bla bla" + environ["PYLINKAHEADINI"] = "bla bla" + assert environ["PYLINKAHEADINI"] == "bla bla" # test wrong configuration file in envvar with pytest.raises(RuntimeError): db.configuration._read_config_files() # test good configuration file in envvar - environ["PYCAOSDBINI"] = "~/.pylinkahead.ini" + environ["PYLINKAHEADINI"] = "~/.pylinkahead.ini" assert expanduser("~/.pylinkahead.ini") in db.configuration._read_config_files() # test without envvar - environ.pop("PYCAOSDBINI") + environ.pop("PYLINKAHEADINI") assert expanduser("~/.pylinkahead.ini") in db.configuration._read_config_files() # test configuration file in cwd assert join(getcwd(), "pylinkahead.ini") in db.configuration._read_config_files()