Skip to content
Snippets Groups Projects
Verified Commit 2f2f3c55 authored by Daniel Hornung's avatar Daniel Hornung
Browse files

Merge branch 'dev' into f-remove-dropoffbox

parents 85d209eb 1faa79e7
No related branches found
No related tags found
2 merge requests!189ENH: add convenience functions,!186Remove dropoff box
Pipeline #62582 passed
Showing
with 1214 additions and 519 deletions
...@@ -57,22 +57,10 @@ mypy: ...@@ -57,22 +57,10 @@ mypy:
tags: [ docker ] tags: [ docker ]
stage: linting stage: linting
script: script:
- pip install mypy types-PyYAML types-jsonschema types-requests types-setuptools types-lxml types-python-dateutil pytest - pip install .[mypy,test]
- make mypy - make mypy
allow_failure: true
# run unit tests # run unit tests
unittest_py3.8:
tags: [ docker ]
stage: test
needs: [ ]
image: python:3.8
script: &python_test_script
# Python docker has problems with tox and pip so use plain pytest here
- touch ~/.pylinkahead.ini
- pip install pynose pytest pytest-cov jsonschema>=4.4.0 setuptools
- pip install .
- python -m pytest unittests
# This needs to be changed once Python 3.9 isn't the standard Python in Debian # This needs to be changed once Python 3.9 isn't the standard Python in Debian
# anymore. # anymore.
...@@ -92,7 +80,11 @@ unittest_py3.10: ...@@ -92,7 +80,11 @@ unittest_py3.10:
stage: test stage: test
needs: [ ] needs: [ ]
image: python:3.10 image: python:3.10
script: *python_test_script script: &python_test_script
# Python docker has problems with tox and pip so use plain pytest here
- touch ~/.pylinkahead.ini
- pip install .[test]
- python -m pytest unittests
unittest_py3.11: unittest_py3.11:
tags: [ docker ] tags: [ docker ]
...@@ -109,19 +101,25 @@ unittest_py3.12: ...@@ -109,19 +101,25 @@ unittest_py3.12:
script: *python_test_script script: *python_test_script
unittest_py3.13: unittest_py3.13:
allow_failure: true
tags: [ docker ] tags: [ docker ]
stage: test stage: test
needs: [ ] needs: [ ]
image: python:3.13-rc image: python:3.13
script: script: *python_test_script
# TODO: Replace by '*python_test_script' as soon as 3.13 has been officially released.
# Python docker has problems with tox and pip so use plain pytest here unittest_py3.14:
- apt update && apt install -y cargo allow_failure: true # remove on release
- touch ~/.pylinkahead.ini tags: [ docker ]
- pip install pynose pytest pytest-cov jsonschema>=4.4.0 setuptools stage: test
- pip install . needs: [ ]
- python -m pytest unittests image: python:3.14-rc
script: # replace by '*python_test_script' on release
# Install cargo manually, source its env, and set it to accept 3.14 as interpreter
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
- . "$HOME/.cargo/env"
- export PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1
# Continue normally
- *python_test_script
# Trigger building of server image and integration tests # Trigger building of server image and integration tests
trigger_build: trigger_build:
...@@ -167,7 +165,7 @@ build-testenv: ...@@ -167,7 +165,7 @@ build-testenv:
pages_prepare: &pages_prepare pages_prepare: &pages_prepare
tags: [ cached-dind ] tags: [ cached-dind ]
stage: deploy stage: deploy
needs: [ code_style, pylint, unittest_py3.8, unittest_py3.9, unittest_py3.10 ] needs: [ code_style, pylint, unittest_py3.9, unittest_py3.10 ]
only: only:
refs: refs:
- /^release-.*$/i - /^release-.*$/i
......
...@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ...@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## ## [Unreleased] ##
### Added ### ### Added ###
- convenience functions `value_matches_versionid`, `get_id_from_versionid` and `get_versionid`
### Changed ### ### Changed ###
...@@ -23,6 +24,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ...@@ -23,6 +24,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Documentation ### ### Documentation ###
* [#78](https://gitlab.com/linkahead/linkahead-pylib/-/issues/78) Fix
and extend test-registration docstrings.
## [0.17.0] - 2025-01-14 ##
### Added ###
* New setup extra `test` which installs the dependencies for testing.
* The Container class has a new member function `filter_by_identity`
which is based on `_filter_entity_list`.
* The `Entity` properties `_cuid` and `_flags` are now available for read-only access
as `cuid` and `flags`, respectively.
### Changed ###
* Renamed the `filter` function of Container, ParentList and
PropertyList to `filter_by_identity`.
### Deprecated ###
* `ParentList.filter` and `PropertyList.filter` functions, use
`filter_by_identity` instead.
### Removed ###
* Support for Python 3.8
### Fixed ###
* [#73](https://gitlab.com/linkahead/linkahead-pylib/-/issues/73)
`Entity.to_xml` now detects potentially infinite recursion and prevents an error
* [#89](https://gitlab.com/linkahead/linkahead-pylib/-/issues/89)
`to_xml` does not add `noscript` or `TransactionBenchmark` tags anymore
* [#103](https://gitlab.com/linkahead/linkahead-pylib/-/issues/103)
`authentication/interface/on_response()` does not overwrite
`auth_token` if new value is `None`
* [#119](https://gitlab.com/linkahead/linkahead-pylib/-/issues/119)
The diff returned by compare_entities now uses id instead of name as
key if either property does not have a name
* [#87](https://gitlab.com/linkahead/linkahead-pylib/-/issues/87)
`XMLSyntaxError` messages when parsing (incomplete) responses in
case of certain connection timeouts.
The diff returned by compare_entities now uses id instead of name as key if either property does not have a name
* [#127](https://gitlab.com/linkahead/linkahead-pylib/-/issues/127)
pylinkahead.ini now supports None and tuples as values for the `timeout` keyword
## [0.16.0] - 2024-11-13 ##
### Added ###
* `ParentList` and `PropertyList` now have a `filter` function that allows to select a subset of
the contained elements by ID and/or name.
* Official support for Python 3.13
* Added arguments to `describe_diff` that allow customizing the labels for the 'old' and the 'new' diffs.
* Optional `realm` argument for `linkahead_admin.py set_user_password`
which defaults to `None`, i.e., the server's default realm.
### Changed ###
* `compare_entities` is now case insensitive with respect to property and
recordtype names
* `_ParentList` is now called `ParentList`
* `_Properties` is now called `PropertyList`
* `ParentList.remove` is now case insensitive when a name is used.
### Deprecated ###
* the use of the arguments `old_entity` and `new_entity` in `compare_entities`
is now deprecated. Please use `entity0` and `entity1` respectively instead.
### Fixed ###
* [gitlab.indiscale.com#200](https://gitlab.indiscale.com/caosdb/src/caosdb-pylib/-/issues/200)
``linkahead_admin.py`` prints reasonable error messages when users
or roles don't exist.
## [0.15.1] - 2024-08-21 ## ## [0.15.1] - 2024-08-21 ##
### Deprecated ### ### Deprecated ###
......
...@@ -20,6 +20,6 @@ authors: ...@@ -20,6 +20,6 @@ authors:
given-names: Stefan given-names: Stefan
orcid: https://orcid.org/0000-0001-7214-8125 orcid: https://orcid.org/0000-0001-7214-8125
title: CaosDB - Pylib title: CaosDB - Pylib
version: 0.15.1 version: 0.17.0
doi: 10.3390/data4020083 doi: 10.3390/data4020083
date-released: 2024-08-21 date-released: 2025-01-14
* caosdb-server >= 0.12.0 * caosdb-server >= 0.12.0
* Python >= 3.8 * Python >= 3.9
* pip >= 20.0.2 * pip >= 20.0.2
Any other dependencies are defined in the setup.py and are being installed via pip Any other dependencies are defined in the setup.py and are being installed via pip
...@@ -40,7 +40,7 @@ style: ...@@ -40,7 +40,7 @@ style:
.PHONY: style .PHONY: style
lint: lint:
pylint --unsafe-load-any-extension=y -d all -e E,F src/linkahead/common pylint --unsafe-load-any-extension=y -d all -e E,F src/linkahead
.PHONY: lint .PHONY: lint
mypy: mypy:
......
...@@ -47,7 +47,7 @@ However, you can also create an issue for it. ...@@ -47,7 +47,7 @@ However, you can also create an issue for it.
* Copyright (C) 2018 Research Group Biomedical Physics, Max Planck Institute * Copyright (C) 2018 Research Group Biomedical Physics, Max Planck Institute
for Dynamics and Self-Organization Göttingen. for Dynamics and Self-Organization Göttingen.
* Copyright (C) 2020-2022 Indiscale GmbH <info@indiscale.com> * Copyright (C) 2020-2025 Indiscale GmbH <info@indiscale.com>
All files in this repository are licensed under a [GNU Affero General Public All files in this repository are licensed under a [GNU Affero General Public
License](LICENCE.md) (version 3 or later). License](LICENCE.md) (version 3 or later).
...@@ -2,24 +2,44 @@ ...@@ -2,24 +2,44 @@
## Installation ## ## Installation ##
### Requirements ### ### How to install ###
First ensure that python with at least version 3.9 is installed. Should this not be
the case, you can use the [Installing python](#installing-python-) guide for your OS.
PyCaosDB needs at least Python 3.8. Additionally, the following packages are required (they will #### Generic installation ####
typically be installed automatically):
- `lxml` To install this LinkAhead python client locally, use `pip`/`pip3`:
- `PyYaml`
- `PySocks`
Optional packages: ```sh
- `keyring` pip install linkahead
```
#### Additional dependencies ####
To test using tox, you also need to install tox:
`pip install tox`
To install dependencies used by optional functionality, the following pip extras
keywords are defined:
- `test` for testing with pytest
- `mypy` for mypy and types
- `jsonschema` - `jsonschema`
- `keyring`
### How to install ### These extras can be installed using:
```sh
pip install .[KEYWORD]
```
A current list of the dependencies installed with this program as well as those installed with
the keywords can be found in `setup.py`s `setup_package()` method, in the `metadata` dictionary
entries `install_requires` and `extras_require`.
### Installing python ###
#### Linux #### #### Linux ####
Make sure that Python (at least version 3.8) and pip is installed, using your system tools and Make sure that Python (at least version 3.9) and pip is installed, using your system tools and
documentation. documentation.
Then open a terminal and continue in the [Generic installation](#generic-installation) section. Then open a terminal and continue in the [Generic installation](#generic-installation) section.
...@@ -51,34 +71,7 @@ cd /Applications/Python\ 3.9/ ...@@ -51,34 +71,7 @@ cd /Applications/Python\ 3.9/
sudo ./Install\ Certificates.command sudo ./Install\ Certificates.command
``` ```
After these steps, you may continue with the [Generic After these steps, you may continue with the [Generic installation](#generic-installation) section.
installation](#generic-installation).
#### Generic installation ####
To install PyCaosDB locally, use `pip3` (also called `pip` on some systems):
```sh
pip3 install --user caosdb
```
---
Alternatively, obtain the sources from GitLab and install from there (`git` must be installed for
this option):
```sh
git clone https://gitlab.com/caosdb/caosdb-pylib
cd caosdb-pylib
pip3 install --user .
```
For installation of optional packages, install with an additional option, e.g. for
validating with the caosdb json schema:
```sh
pip3 install --user .[jsonschema]
```
## Configuration ## ## Configuration ##
...@@ -87,7 +80,7 @@ is described in detail in the [configuration section of the documentation](https ...@@ -87,7 +80,7 @@ is described in detail in the [configuration section of the documentation](https
## Try it out ## ## Try it out ##
Start Python and check whether the you can access the database. (You will be asked for the Start Python and check whether you can access the database. (You will be asked for the
password): password):
```python ```python
...@@ -107,6 +100,7 @@ Now would be a good time to continue with the [tutorials](tutorials/index). ...@@ -107,6 +100,7 @@ Now would be a good time to continue with the [tutorials](tutorials/index).
- Run all tests: `tox` or `make unittest` - Run all tests: `tox` or `make unittest`
- Run a specific test file: e.g. `tox -- unittests/test_schema.py` - Run a specific test file: e.g. `tox -- unittests/test_schema.py`
- Run a specific test function: e.g. `tox -- unittests/test_schema.py::test_config_files` - Run a specific test function: e.g. `tox -- unittests/test_schema.py::test_config_files`
- To run using pytest: `pytest .`
## Documentation ## ## Documentation ##
We use sphinx to create the documentation. Docstrings in the code should comply We use sphinx to create the documentation. Docstrings in the code should comply
...@@ -114,13 +108,6 @@ with the Googly style (see link below). ...@@ -114,13 +108,6 @@ with the Googly style (see link below).
Build documentation in `build/` with `make doc`. Build documentation in `build/` with `make doc`.
### Requirements ###
- `sphinx`
- `sphinx-autoapi`
- `recommonmark`
- `sphinx_rtd_theme`
### How to contribute ### ### How to contribute ###
- [Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) - [Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html)
...@@ -128,7 +115,7 @@ Build documentation in `build/` with `make doc`. ...@@ -128,7 +115,7 @@ Build documentation in `build/` with `make doc`.
- [References to other documentation](https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#role-external) - [References to other documentation](https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#role-external)
### Troubleshooting ### ### Troubleshooting ###
If the client is to be executed directly from the `/src` folder, an initial `.\setup.py install --user` must be called. If the client is to be executed directly from the `/src` folder, an initial `.\setup.py install` must be called.
## Migration ## ## Migration ##
TODO TODO
...@@ -48,8 +48,8 @@ from setuptools import find_packages, setup ...@@ -48,8 +48,8 @@ from setuptools import find_packages, setup
ISRELEASED = False ISRELEASED = False
MAJOR = 0 MAJOR = 0
MINOR = 15 MINOR = 17
MICRO = 2 MICRO = 1
# Do not tag as pre-release until this commit # Do not tag as pre-release until this commit
# https://github.com/pypa/packaging/pull/515 # https://github.com/pypa/packaging/pull/515
# has made it into a release. Probably we should wait for pypa/packaging>=21.4 # has made it into a release. Probably we should wait for pypa/packaging>=21.4
...@@ -179,7 +179,7 @@ def setup_package(): ...@@ -179,7 +179,7 @@ def setup_package():
"Topic :: Scientific/Engineering :: Information Analysis", "Topic :: Scientific/Engineering :: Information Analysis",
], ],
packages=find_packages('src'), packages=find_packages('src'),
python_requires='>=3.8', python_requires='>=3.9',
package_dir={'': 'src'}, package_dir={'': 'src'},
install_requires=['lxml>=4.6.3', install_requires=['lxml>=4.6.3',
"requests[socks]>=2.26", "requests[socks]>=2.26",
...@@ -187,11 +187,27 @@ def setup_package(): ...@@ -187,11 +187,27 @@ def setup_package():
'PyYAML>=5.4.1', 'PyYAML>=5.4.1',
'future', 'future',
], ],
extras_require={'keyring': ['keyring>=13.0.0'], extras_require={
'jsonschema': ['jsonschema>=4.4.0']}, "jsonschema": ["jsonschema>=4.4.0"],
"keyring": ["keyring>=13.0.0"],
"mypy": [
"mypy",
"types-PyYAML",
"types-jsonschema",
"types-requests",
"types-setuptools",
"types-lxml",
"types-python-dateutil",
],
"test": [
"pytest",
"pytest-cov",
"coverage>=4.4.2",
"jsonschema>=4.4.0",
]
},
setup_requires=["pytest-runner>=2.0,<3dev"], setup_requires=["pytest-runner>=2.0,<3dev"],
tests_require=["pytest", "pytest-cov", "coverage>=4.4.2",
"jsonschema>=4.4.0"],
package_data={ package_data={
'linkahead': ['py.typed', 'cert/indiscale.ca.crt', 'schema-pycaosdb-ini.yml'], 'linkahead': ['py.typed', 'cert/indiscale.ca.crt', 'schema-pycaosdb-ini.yml'],
}, },
......
...@@ -25,14 +25,14 @@ import sphinx_rtd_theme # noqa: E402 ...@@ -25,14 +25,14 @@ import sphinx_rtd_theme # noqa: E402
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = 'pylinkahead' project = 'pylinkahead'
copyright = '2023, IndiScale GmbH' copyright = '2024, IndiScale GmbH'
author = 'Daniel Hornung' author = 'Daniel Hornung'
# The short X.Y version # The short X.Y version
version = '0.15.2' version = '0.17.1'
# The full version, including alpha/beta/rc tags # The full version, including alpha/beta/rc tags
# release = '0.5.2-rc2' # release = '0.5.2-rc2'
release = '0.15.2-dev' release = '0.17.1-dev'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
......
...@@ -75,3 +75,70 @@ Examples ...@@ -75,3 +75,70 @@ Examples
b = input("Press any key to cleanup.") b = input("Press any key to cleanup.")
# cleanup everything after the user presses any button. # cleanup everything after the user presses any button.
c.delete() c.delete()
Finding parents and properties
------------------------------
To find a specific parent or property of an Entity, its
ParentList or PropertyList can be filtered using names, ids, or
entities. A short example:
.. code-block:: python3
import linkahead as db
# Setup a record with six properties
r = db.Record()
p1_1 = db.Property(id=101, name="Property 1")
p1_2 = db.Property(name="Property 1")
p2_1 = db.Property(id=102, name="Property 2")
p2_2 = db.Property(id=102)
p2_3 = db.Property(id=102, name="Other Property")
p3 = db.Property(id=104, name="Other Property")
r.add_property(p1_1).add_property(p1_2).add_property(p2_1)
r.add_property(p2_2).add_property(p2_3).add_property(p3)
properties = r.properties
# As r only has one property with id 101, this returns a list containing only p1_1
properties.filter_by_identity(pid=101)
# Result: [p1_1]
# Filtering with name="Property 1" returns both p1_1 and p1_2, as they share their name
properties.filter_by_identity(name="Property 1")
# Result: [p1_1, p1_2]
# If both name and pid are given, matching is based only on pid for all entities that have an id
properties.filter_by_identity(pid="102", name="Other Property")
# Result: [p2_1, p2_2, p2_3]
# However, filter_by_identity with name="Property 1" and id=101 returns both p1_1 and p1_2, because
# p1_2 does not have an id and matches the name
properties.filter_by_identity(pid="101", name="Property 1")
# Result: [p1_1, p1_2]
# We can also filter using an entity, in which case the name and id of the entity are used:
properties.filter_by_identity(pid="102", name="Property 2") == properties.filter_by_identity(p2_1)
# Result: True
# If we only need properties that match both id and name, we can set the parameter
# conjunction to True:
properties.filter_by_identity(pid="102", name="Property 2", conjunction=True)
# Result: [p2_1]
The filter function of ParentList works analogously.
Finding entities in a Container
-------------------------------
In the same way as described above, Container can be filtered.
A short example:
.. code-block:: python3
import linkahead as db
# Setup a record with six properties
p1 = db.Property(id=101, name="Property 1")
p2 = db.Property(name="Property 2")
c = db.Container().extend([p1,p2])
c.filter_by_identity(name="Property 1")
# Result: [p1]
...@@ -42,7 +42,7 @@ from .common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, LIST, ...@@ -42,7 +42,7 @@ from .common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, LIST,
REFERENCE, TEXT) REFERENCE, TEXT)
# Import of the basic API classes: # Import of the basic API classes:
from .common.models import (ACL, ALL, FIX, NONE, OBLIGATORY, RECOMMENDED, from .common.models import (ACL, ALL, FIX, NONE, OBLIGATORY, RECOMMENDED,
SUGGESTED, Container, Entity, File, SUGGESTED, Container, Entity, File, Parent,
Info, Message, Permissions, Property, Query, Info, Message, Permissions, Property, Query,
QueryTemplate, Record, RecordType, delete, QueryTemplate, Record, RecordType, delete,
execute_query, get_global_acl, execute_query, get_global_acl,
...@@ -55,7 +55,7 @@ from .utils.get_entity import (get_entity_by_id, get_entity_by_name, ...@@ -55,7 +55,7 @@ from .utils.get_entity import (get_entity_by_id, get_entity_by_name,
get_entity_by_path) get_entity_by_path)
try: try:
from .version import version as __version__ from .version import version as __version__ # pylint: disable=import-error
except ModuleNotFoundError: except ModuleNotFoundError:
version = "uninstalled" version = "uninstalled"
__version__ = version __version__ = version
......
This diff is collapsed.
...@@ -165,7 +165,8 @@ Returns ...@@ -165,7 +165,8 @@ Returns
------- -------
out: named tuple out: named tuple
See the standard library :func:`functools.lru_cache` for details.""" See the standard library :func:`functools.lru_cache` for details.
"""
return _cached_access.cache_info() return _cached_access.cache_info()
......
...@@ -91,7 +91,7 @@ def get_server_properties() -> dict[str, Optional[str]]: ...@@ -91,7 +91,7 @@ def get_server_properties() -> dict[str, Optional[str]]:
props: dict[str, Optional[str]] = dict() props: dict[str, Optional[str]] = dict()
for elem in xml.getroot(): for elem in xml.getroot():
props[elem.tag] = elem.text props[str(elem.tag)] = str(elem.text)
return props return props
...@@ -156,7 +156,10 @@ def generate_password(length: int): ...@@ -156,7 +156,10 @@ def generate_password(length: int):
def _retrieve_user(name: str, realm: Optional[str] = None, **kwargs): def _retrieve_user(name: str, realm: Optional[str] = None, **kwargs):
con = get_connection() con = get_connection()
try: try:
return con._http_request(method="GET", path="User/" + (realm + "/" + name if realm is not None else name), **kwargs).read() return con._http_request(
method="GET",
path="User/" + (realm + "/" + name if realm is not None else name),
**kwargs).read()
except HTTPForbiddenError as e: except HTTPForbiddenError as e:
e.msg = "You are not permitted to retrieve this user." e.msg = "You are not permitted to retrieve this user."
raise raise
...@@ -198,7 +201,9 @@ def _update_user(name: str, ...@@ -198,7 +201,9 @@ def _update_user(name: str,
if entity is not None: if entity is not None:
params["entity"] = str(entity) params["entity"] = str(entity)
try: try:
return con.put_form_data(entity_uri_segment="User/" + (realm + "/" + name if realm is not None else name), params=params, **kwargs).read() return con.put_form_data(entity_uri_segment="User/" + (realm + "/" +
name if realm is not None else name),
params=params, **kwargs).read()
except HTTPResourceNotFoundError as e: except HTTPResourceNotFoundError as e:
e.msg = "User does not exist." e.msg = "User does not exist."
raise e raise e
...@@ -246,7 +251,9 @@ def _insert_user(name: str, ...@@ -246,7 +251,9 @@ def _insert_user(name: str,
def _insert_role(name, description, **kwargs): def _insert_role(name, description, **kwargs):
con = get_connection() con = get_connection()
try: try:
return con.post_form_data(entity_uri_segment="Role", params={"role_name": name, "role_description": description}, **kwargs).read() return con.post_form_data(entity_uri_segment="Role",
params={"role_name": name, "role_description": description},
**kwargs).read()
except HTTPForbiddenError as e: except HTTPForbiddenError as e:
e.msg = "You are not permitted to insert a new role." e.msg = "You are not permitted to insert a new role."
raise raise
...@@ -259,7 +266,9 @@ def _insert_role(name, description, **kwargs): ...@@ -259,7 +266,9 @@ def _insert_role(name, description, **kwargs):
def _update_role(name, description, **kwargs): def _update_role(name, description, **kwargs):
con = get_connection() con = get_connection()
try: try:
return con.put_form_data(entity_uri_segment="Role/" + name, params={"role_description": description}, **kwargs).read() return con.put_form_data(entity_uri_segment="Role/" + name,
params={"role_description": description},
**kwargs).read()
except HTTPForbiddenError as e: except HTTPForbiddenError as e:
e.msg = "You are not permitted to update this role." e.msg = "You are not permitted to update this role."
raise raise
...@@ -301,8 +310,10 @@ def _set_roles(username, roles, realm=None, **kwargs): ...@@ -301,8 +310,10 @@ def _set_roles(username, roles, realm=None, **kwargs):
body = xml2str(xml) body = xml2str(xml)
con = get_connection() con = get_connection()
try: try:
body = con._http_request(method="PUT", path="UserRoles/" + (realm + "/" + body = con._http_request(method="PUT",
username if realm is not None else username), body=body, **kwargs).read() path="UserRoles/" + (realm + "/" +
username if realm is not None else username),
body=body, **kwargs).read()
except HTTPForbiddenError as e: except HTTPForbiddenError as e:
e.msg = "You are not permitted to set this user's roles." e.msg = "You are not permitted to set this user's roles."
raise raise
...@@ -369,7 +380,8 @@ Returns ...@@ -369,7 +380,8 @@ Returns
body = xml2str(xml) body = xml2str(xml)
con = get_connection() con = get_connection()
try: try:
return con._http_request(method="PUT", path="PermissionRules/" + role, body=body, **kwargs).read() return con._http_request(method="PUT", path="PermissionRules/" + role, body=body,
**kwargs).read()
except HTTPForbiddenError as e: except HTTPForbiddenError as e:
e.msg = "You are not permitted to set this role's permissions." e.msg = "You are not permitted to set this role's permissions."
raise raise
...@@ -381,7 +393,9 @@ Returns ...@@ -381,7 +393,9 @@ Returns
def _get_permissions(role, **kwargs): def _get_permissions(role, **kwargs):
con = get_connection() con = get_connection()
try: try:
return PermissionRule._parse_body(con._http_request(method="GET", path="PermissionRules/" + role, **kwargs).read()) return PermissionRule._parse_body(con._http_request(method="GET",
path="PermissionRules/" + role,
**kwargs).read())
except HTTPForbiddenError as e: except HTTPForbiddenError as e:
e.msg = "You are not permitted to retrieve this role's permissions." e.msg = "You are not permitted to retrieve this role's permissions."
raise raise
...@@ -429,7 +443,8 @@ priority : bool, optional ...@@ -429,7 +443,8 @@ priority : bool, optional
if permission is None: if permission is None:
raise ValueError(f"Permission is missing in PermissionRule xml: {elem}") raise ValueError(f"Permission is missing in PermissionRule xml: {elem}")
priority = PermissionRule._parse_boolean(elem.get("priority")) priority = PermissionRule._parse_boolean(elem.get("priority"))
return PermissionRule(elem.tag, permission, priority if priority is not None else False) return PermissionRule(str(elem.tag), permission,
priority if priority is not None else False)
@staticmethod @staticmethod
def _parse_body(body: str): def _parse_body(body: str):
......
This diff is collapsed.
...@@ -20,11 +20,11 @@ ...@@ -20,11 +20,11 @@
# ** end header # ** end header
from __future__ import annotations # Can be removed with 3.10. from __future__ import annotations # Can be removed with 3.10.
import copy
from lxml import etree
import copy
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import sys
from lxml import etree
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Optional from typing import Optional
...@@ -87,7 +87,8 @@ class Transition: ...@@ -87,7 +87,8 @@ class Transition:
return self._to_state return self._to_state
def __repr__(self): def __repr__(self):
return f'Transition(name="{self.name}", from_state="{self.from_state}", to_state="{self.to_state}", description="{self.description}")' return (f'Transition(name="{self.name}", from_state="{self.from_state}", '
f'to_state="{self.to_state}", description="{self.description}")')
def __eq__(self, other): def __eq__(self, other):
return ( return (
...@@ -103,9 +104,9 @@ class Transition: ...@@ -103,9 +104,9 @@ class Transition:
@staticmethod @staticmethod
def from_xml(xml: etree._Element) -> "Transition": def from_xml(xml: etree._Element) -> "Transition":
to_state = [to.get("name") to_state = [to.get("name")
for to in xml if to.tag.lower() == "tostate"] for to in xml if str(to.tag).lower() == "tostate"]
from_state = [ from_state = [
from_.get("name") for from_ in xml if from_.tag.lower() == "fromstate" from_.get("name") for from_ in xml if str(from_.tag).lower() == "fromstate"
] ]
return Transition( return Transition(
name=xml.get("name"), name=xml.get("name"),
...@@ -199,7 +200,7 @@ class State: ...@@ -199,7 +200,7 @@ class State:
result._id = xml.get("id") result._id = xml.get("id")
result._description = xml.get("description") result._description = xml.get("description")
transitions = [ transitions = [
Transition.from_xml(t) for t in xml if t.tag.lower() == "transition" Transition.from_xml(t) for t in xml if str(t.tag).lower() == "transition"
] ]
if transitions: if transitions:
result._transitions = set(transitions) result._transitions = set(transitions)
......
...@@ -101,11 +101,14 @@ class Version(): ...@@ -101,11 +101,14 @@ class Version():
# pylint: disable=redefined-builtin # pylint: disable=redefined-builtin
def __init__(self, id: Optional[str] = None, date: Optional[str] = None, def __init__(self, id: Optional[str] = None, date: Optional[str] = None,
username: Optional[str] = None, realm: Optional[str] = None, username: Optional[str] = None, realm: Optional[str] = None,
predecessors: Optional[List[Version]] = None, successors: Optional[List[Version]] = None, predecessors: Optional[List[Version]] = None,
successors: Optional[List[Version]] = None,
is_head: Union[bool, str, None] = False, is_head: Union[bool, str, None] = False,
is_complete_history: Union[bool, str, None] = False): is_complete_history: Union[bool, str, None] = False):
"""Typically the `predecessors` or `successors` should not "link back" to an existing Version """Typically the `predecessors` or `successors` should not "link back" to an existing
object.""" Version object.
"""
self.id = id self.id = id
self.date = date self.date = date
self.username = username self.username = username
...@@ -205,8 +208,8 @@ object.""" ...@@ -205,8 +208,8 @@ object."""
version : Version version : Version
a new version instance a new version instance
""" """
predecessors = [Version.from_xml(p) for p in xml if p.tag.lower() == "predecessor"] predecessors = [Version.from_xml(p) for p in xml if str(p.tag).lower() == "predecessor"]
successors = [Version.from_xml(s) for s in xml if s.tag.lower() == "successor"] successors = [Version.from_xml(s) for s in xml if str(s.tag).lower() == "successor"]
return Version(id=xml.get("id"), date=xml.get("date"), return Version(id=xml.get("id"), date=xml.get("date"),
is_head=xml.get("head"), is_head=xml.get("head"),
is_complete_history=xml.get("completeHistory"), is_complete_history=xml.get("completeHistory"),
......
...@@ -30,6 +30,15 @@ import yaml ...@@ -30,6 +30,15 @@ import yaml
try: try:
optional_jsonschema_validate: Optional[Callable] = None optional_jsonschema_validate: Optional[Callable] = None
from jsonschema import validate as optional_jsonschema_validate from jsonschema import validate as optional_jsonschema_validate
# Adapted from https://github.com/python-jsonschema/jsonschema/issues/148
# Defines Validator to allow parsing of all iterables as array in jsonschema
# CustomValidator can be removed if/once jsonschema allows tuples for arrays
from collections.abc import Iterable
from jsonschema import validators
default = validators.validator_for(True) # Returns latest supported draft
t_c = (default.TYPE_CHECKER.redefine('array', lambda x, y: isinstance(y, Iterable)))
CustomValidator = validators.extend(default, type_checker=t_c)
except ImportError: except ImportError:
pass pass
...@@ -72,13 +81,39 @@ def get_config() -> ConfigParser: ...@@ -72,13 +81,39 @@ def get_config() -> ConfigParser:
return _pycaosdbconf return _pycaosdbconf
def config_to_yaml(config: ConfigParser) -> dict[str, dict[str, Union[int, str, bool]]]: def config_to_yaml(config: ConfigParser) -> dict[str, dict[str, Union[int, str, bool, tuple, None]]]:
valobj: dict[str, dict[str, Union[int, str, bool]]] = {} """
Generates and returns a dict with all config options and their values
defined in the config.
The values of the options 'debug', 'timeout', and 'ssl_insecure' are
parsed, all other values are saved as string.
Parameters
----------
config : ConfigParser
The config to be converted to a dict
Returns
-------
valobj : dict
A dict with config options and their values as key value pairs
"""
valobj: dict[str, dict[str, Union[int, str, bool, tuple, None]]] = {}
for s in config.sections(): for s in config.sections():
valobj[s] = {} valobj[s] = {}
for key, value in config[s].items(): for key, value in config[s].items():
# TODO: Can the type be inferred from the config object? # TODO: Can the type be inferred from the config object?
if key in ["timeout", "debug"]: if key in ["debug"]:
valobj[s][key] = int(value)
elif key in ["timeout"]:
value = "".join(value.split()) # Remove whitespace
if str(value).lower() in ["none", "null"]:
valobj[s][key] = None
elif value.startswith('(') and value.endswith(')'):
content = [None if str(s).lower() in ["none", "null"] else int(s)
for s in value[1:-1].split(',')]
valobj[s][key] = tuple(content)
else:
valobj[s][key] = int(value) valobj[s][key] = int(value)
elif key in ["ssl_insecure"]: elif key in ["ssl_insecure"]:
valobj[s][key] = bool(value) valobj[s][key] = bool(value)
...@@ -88,11 +123,12 @@ def config_to_yaml(config: ConfigParser) -> dict[str, dict[str, Union[int, str, ...@@ -88,11 +123,12 @@ def config_to_yaml(config: ConfigParser) -> dict[str, dict[str, Union[int, str,
return valobj return valobj
def validate_yaml_schema(valobj: dict[str, dict[str, Union[int, str, bool]]]): def validate_yaml_schema(valobj: dict[str, dict[str, Union[int, str, bool, tuple, None]]]):
if optional_jsonschema_validate: if optional_jsonschema_validate:
with open(os.path.join(os.path.dirname(__file__), "schema-pycaosdb-ini.yml")) as f: with open(os.path.join(os.path.dirname(__file__), "schema-pycaosdb-ini.yml")) as f:
schema = yaml.load(f, Loader=yaml.SafeLoader) schema = yaml.load(f, Loader=yaml.SafeLoader)
optional_jsonschema_validate(instance=valobj, schema=schema["schema-pycaosdb-ini"]) optional_jsonschema_validate(instance=valobj, schema=schema["schema-pycaosdb-ini"],
cls=CustomValidator)
else: else:
warnings.warn(""" warnings.warn("""
Warning: The validation could not be performed because `jsonschema` is not installed. Warning: The validation could not be performed because `jsonschema` is not installed.
......
...@@ -125,8 +125,9 @@ class AbstractAuthenticator(ABC): ...@@ -125,8 +125,9 @@ class AbstractAuthenticator(ABC):
Returns Returns
------- -------
""" """
self.auth_token = parse_auth_token( new_token = parse_auth_token(response.getheader("Set-Cookie"))
response.getheader("Set-Cookie")) if new_token is not None:
self.auth_token = new_token
def on_request(self, method: str, path: str, headers: QueryDict, **kwargs): def on_request(self, method: str, path: str, headers: QueryDict, **kwargs):
# pylint: disable=unused-argument # pylint: disable=unused-argument
...@@ -190,7 +191,7 @@ class CredentialsAuthenticator(AbstractAuthenticator): ...@@ -190,7 +191,7 @@ class CredentialsAuthenticator(AbstractAuthenticator):
def _logout(self): def _logout(self):
self.logger.debug("[LOGOUT]") self.logger.debug("[LOGOUT]")
if self.auth_token is not None: if self.auth_token is not None:
self._connection.request(method="DELETE", path="logout") self._connection.request(method="GET", path="logout")
self.auth_token = None self.auth_token = None
def _login(self): def _login(self):
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment