Skip to content

Commit

Permalink
Merge pull request #60 from delb-xml/delb0.4
Browse files Browse the repository at this point in the history
Updates to delb 0.4
  • Loading branch information
03b8 authored Nov 8, 2022
2 parents 5a66068 + 2318a42 commit 91005e8
Show file tree
Hide file tree
Showing 9 changed files with 480 additions and 181 deletions.
501 changes: 395 additions & 106 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ snakesist = "snakesist.delb_plugins"

[tool.poetry.dependencies]
python = "^3.7"
delb = { version="^0.3", extras=["https-loader"] }
delb = { version="^0.4", extras=["https-loader"] }
importlib_metadata = { version="*", python = "<3.8" }

[tool.poetry.dev-dependencies]
delb-reference-plugins = "^0.3"
delb-reference-plugins = "^0.4"
docker-compose = "^1.29.2"
lxml-stubs = "*"
pytest = "^6.2"
Expand Down
11 changes: 9 additions & 2 deletions snakesist/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
from pkg_resources import get_distribution
import sys

from snakesist.exist_client import ExistClient, NodeResource

__version__ = get_distribution("snakesist").version
if sys.version_info < (3, 8):
from importlib_metadata import version
else:
from importlib.metadata import version


__version__ = version("snakesist")
43 changes: 17 additions & 26 deletions snakesist/delb_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
from urllib.parse import urlparse
from warnings import warn

import requests
from _delb.plugins import plugin_manager, DocumentExtensionHooks
from _delb.plugins.core_loaders import ftp_http_loader
import httpx
from _delb.plugins import plugin_manager, DocumentMixinBase
from _delb.plugins.https_loader import https_loader
from _delb.typing import LoaderResult
from lxml import etree
Expand Down Expand Up @@ -96,25 +95,16 @@ def load_from_url(source: Any, config: SimpleNamespace) -> LoaderResult:


def load_from_path(source: Any, config: SimpleNamespace) -> LoaderResult:
try:
if isinstance(source, str):
source = _mangle_path(source)
if not isinstance(source, PurePosixPath):
raise TypeError
except Exception:
return "The input value is not a proper path."
else:
path = source
path = _mangle_path(source) if isinstance(source, str) else source
if not isinstance(path, PurePosixPath):
raise TypeError

client: ExistClient = config.existdb.client
url = f"{client.root_collection_url}/{path}"

try:
if client.transport == "https":
result = https_loader(url, config)
else: # http
result = ftp_http_loader(url, config)
except requests.HTTPError as e:
result = https_loader(url, config, client=client.http_client)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise SnakesistNotFound(f"Document '{path}' not found.")
raise SnakesistReadError("Could not read from database.") from e
Expand All @@ -137,16 +127,14 @@ def wrapper(self, *args, **kwargs):
return wrapper


@plugin_manager.register_document_extension
class ExistDBExtension(DocumentExtensionHooks):
class ExistDBExtension(DocumentMixinBase):
"""
This class provides extensions to :class:`delb.Document` in order to interact
with a eXist-db instance.
See :func:`existdb_loader` on retrieving documents from an eXist-db instance.
"""

# for mypy:
config: SimpleNamespace

@classmethod
Expand Down Expand Up @@ -221,19 +209,22 @@ def existdb_store(
else:
_validate_filename(filename)

http_client = self.config.existdb.client.http_client
url = f"{client.root_collection_url}/{collection}/{filename}"

if not replace_existing and requests.head(url).status_code == 200:
if not replace_existing and http_client.head(url).status_code == 200:
raise SnakesistWriteError(
"Document already exists. Overwriting must be explicitly allowed."
"Document already exists. Overwriting must be allowed explicitly."
)

response = requests.put(
url, headers={"Content-Type": "application/xml"}, data=str(self).encode(),
response = http_client.put(
url,
headers={"Content-Type": "application/xml"},
content=str(self),
)
if not response.status_code == 201:
raise SnakesistWriteError(f"Unexpected response: {response}")
try:
response.raise_for_status()
except Exception as e:
raise SnakesistWriteError("Unhandled error while storing.") from e
if not response.status_code == 201:
raise SnakesistWriteError(f"Unexpected response: {response}")
57 changes: 32 additions & 25 deletions snakesist/exist_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
from typing import List, NamedTuple, Optional, Tuple
from urllib.parse import urlparse

import requests
import httpx
from _delb.nodes import NodeBase, _wrapper_cache
from _delb.parser import ParserOptions, _compat_get_parser
from lxml import cssselect, etree

from snakesist.exceptions import (
Expand Down Expand Up @@ -46,21 +47,20 @@ class ConnectionProps(NamedTuple):
TRANSPORT_PROTOCOLS = {"https": 443, "http": 80} # the order matters!
XML_NAMESPACE = "https://snakesist.readthedocs.io/"
XQUERY_PAYLOAD_TEMPLATE = (
'<query '
"<query "
f'xmlns="{EXISTDB_NAMESPACE}" '
'start="1" '
'max="0" '
'cache="no">'
'<text> <![CDATA[{query}]]></text>'
'<properties>'
"<text> <![CDATA[{query}]]></text>"
"<properties>"
'<property name="indent" value="no"/>'
'<property name="wrap" value="yes"/>'
'</properties>'
'</query>'
"</properties>"
"</query>"
)



fetch_resource_paths = cssselect.CSSSelector(
"x|result x|value", namespaces={"x": EXISTDB_NAMESPACE}
)
Expand Down Expand Up @@ -127,15 +127,15 @@ def update_pull(self):
)

def update_push(self):
""" Writes the node to the database. """
"""Writes the node to the database."""
self._exist_client.update_node(
data=str(self.node),
abs_resource_id=self._abs_resource_id,
node_id=self._node_id,
)

def delete(self):
""" Deletes the node from the database. """
"""Deletes the node from the database."""
self._exist_client.delete_node(
abs_resource_id=self._abs_resource_id, node_id=self._node_id
)
Expand Down Expand Up @@ -179,7 +179,8 @@ class ExistClient:
:param prefix: configured path prefix for the eXist instance
:param root_collection: a path to a collection which will be used as root for all
document paths
:param parser: an lxml etree.XMLParser instance to parse query results
:param parser: deprecated
:param parser_options: a named tuple from delb to define the XML parser's behaviour
"""

def __init__(
Expand All @@ -191,7 +192,8 @@ def __init__(
password: str = DEFAULT_PASSWORD,
prefix: str = "exist",
root_collection: str = "/",
parser: etree.XMLParser = DEFAULT_PARSER,
parser: etree.XMLParser = None,
parser_options: ParserOptions = None,
):
_prefix = _mangle_path(prefix)
self.__connection_props = ConnectionProps(
Expand All @@ -203,7 +205,10 @@ def __init__(
prefix=_prefix,
)
self.__base_url = f"{transport}://{user}:{password}@{host}:{port}/{_prefix}"
self.parser = parser
self.http_client = httpx.Client(http2=True)
self.parser, _ = _compat_get_parser(
parser=parser, parser_options=parser_options, collapse_whitesppace=True
)
self.root_collection = root_collection

@classmethod
Expand Down Expand Up @@ -272,8 +277,8 @@ def _probe_transport_and_port(
for transport, default_port in TRANSPORT_PROTOCOLS.items():
_port = port or default_port
try:
requests.head(f"{transport}://{host}:{_port}/")
except requests.exceptions.ConnectionError:
httpx.head(f"{transport}://{host}:{_port}/")
except httpx.TransportError:
pass
else:
return transport, _port
Expand All @@ -287,7 +292,7 @@ def _probe_instance_prefix(base: str, path: str) -> Optional[str]:
# looks for longest path as different instances could have overlapping prefixes
# will return false results if a path contained a part named "rest"
for i in range(len(path_parts), 0, -1):
response = requests.get(f"{base}/{'/'.join(path_parts[:i])}/rest/")
response = httpx.get(f"{base}/{'/'.join(path_parts[:i])}/rest/")

if response.status_code == 401:
raise SnakesistConfigError("Failed authentication.")
Expand Down Expand Up @@ -320,42 +325,42 @@ def base_url(self) -> str:
return self.__base_url

@property
def transport(self):
def transport(self) -> str:
"""
The used transport protocol
"""
return self.__connection_props.transport

@property
def host(self):
def host(self) -> str:
"""
The database hostname
"""
return self.__connection_props.host

@property
def port(self):
def port(self) -> str:
"""
The database port number
"""
return self.__connection_props.port

@property
def user(self):
def user(self) -> str:
"""
The user name used to connect to the database
"""
return self.__connection_props.user

@property
def password(self):
def password(self) -> str:
"""
The password used to connect to the database
"""
return self.__connection_props.password

@property
def prefix(self):
def prefix(self) -> str:
"""
The URL prefix of the database
"""
Expand Down Expand Up @@ -396,10 +401,10 @@ def query(self, query_expression: str) -> etree._Element:
:return: The query result as a ``delb.Document`` object.
"""

response = requests.post(
response = self.http_client.post(
self.root_collection_url,
headers={"Content-Type": "application/xml"},
data=XQUERY_PAYLOAD_TEMPLATE.format(query=query_expression).encode(),
content=XQUERY_PAYLOAD_TEMPLATE.format(query=query_expression),
)

try:
Expand Down Expand Up @@ -462,7 +467,9 @@ def retrieve_resource(self, abs_resource_id: str, node_id: str) -> NodeResource:

return NodeResource(
self,
QueryResultItem(abs_resource_id, node_id, path, _wrapper_cache(queried_node)),
QueryResultItem(
abs_resource_id, node_id, path, _wrapper_cache(queried_node)
),
)

def update_node(self, data: str, abs_resource_id: str, node_id: str) -> None:
Expand Down Expand Up @@ -498,7 +505,7 @@ def delete_document(self, document_path: str) -> None:
:param document_path: The path pointing to the document within the root
collection.
"""
response = requests.delete(
response = self.http_client.delete(
f"{self.root_collection_url}/{_mangle_path(document_path)}"
)
if response.status_code == 404:
Expand Down
13 changes: 6 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from os import getenv
from pathlib import Path

import requests
from pytest import fixture # type: ignore
import httpx
from pytest import fixture

from snakesist import ExistClient

Expand All @@ -17,8 +17,7 @@ def rest_base_url(test_client):

def existdb_is_responsive(url):
try:
response = requests.head(url)
assert response.status_code == 200
httpx.head(url).raise_for_status()
except Exception:
return False
else:
Expand All @@ -31,14 +30,14 @@ def db(docker_ip, docker_services, monkeypatch):
Database setup and teardown
"""
monkeypatch.setenv(
"REQUESTS_CA_BUNDLE",
"SSL_CERT_FILE",
str(Path(__file__).resolve().parent / "db_fixture" / "nginx" / "cert.pem"),
)
base_url = f"http://{docker_ip}:{docker_services.port_for('existdb', 8080)}"
docker_services.wait_until_responsive(
timeout=30.0, pause=0.1, check=lambda: existdb_is_responsive(base_url)
)
return base_url
yield base_url


@fixture(scope="session")
Expand All @@ -60,7 +59,7 @@ def test_client(db):

global exist_version_is_verified
if not exist_version_is_verified:
assert getenv("EXIST_VERSION") == client.query('system:get-version()')[0].text
assert getenv("EXIST_VERSION") == client.query("system:get-version()")[0].text
exist_version_is_verified = True

yield client
8 changes: 4 additions & 4 deletions tests/test_db_fixture.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import pytest # type: ignore
import requests
import pytest
import httpx


def test_db_instance_exists(db):
response = requests.get(f"{db}/exist/")
response = httpx.get(f"{db}/exist/", follow_redirects=True)
response.raise_for_status()


@pytest.mark.parametrize("doc_path", ["dada_manifest.xml"])
def test_documents_exist(db, doc_path):
response = requests.get(f"{db}/exist/rest/db/apps/test-data/{doc_path}")
response = httpx.get(f"{db}/exist/rest/db/apps/test-data/{doc_path}")
assert response.status_code == 200
Loading

0 comments on commit 91005e8

Please sign in to comment.