From 58abf4c2e5a7e3fdad358ee0dfa7a3661505785c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:19:35 +0200 Subject: [PATCH 1/4] [client-python] chore(deps): update dependency requests to >=2.33.1,<2.34.0 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 45e30a1..4037c40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "PyYAML (>=6.0,<6.1)", "pydantic (>=2.11.3,<2.12.0)", "pydantic-settings (>=2.11.0,<2.12.0)", - "requests (>=2.32.3,<2.33.0)", + "requests (>=2.33.1,<2.34.0)", "setuptools (>=80.9.0,<80.10.0)", "cachetools (>=5.5.0,<5.6.0)", "prometheus-client (>=0.22.1,<0.23.0)", From ff61d2f2ebfc8de38a06357024d74d5ef8c1ddbc Mon Sep 17 00:00:00 2001 From: Mariot Tsitoara Date: Mon, 4 May 2026 07:59:53 +0000 Subject: [PATCH 2/4] [tool] fix(deprecation): move pythonjsonlogger.jsonlogger to pythonjsonlogger.json (#212) --- pyoaev/utils.py | 4 +- pyproject.toml | 5 + .../test_connector_config_schema_generator.py | 106 +++++++ test/test_utils.py | 259 ++++++++++++++++++ 4 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 test/configuration/test_connector_config_schema_generator.py create mode 100644 test/test_utils.py diff --git a/pyoaev/utils.py b/pyoaev/utils.py index c620152..83227b3 100644 --- a/pyoaev/utils.py +++ b/pyoaev/utils.py @@ -8,7 +8,7 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union import requests -from pythonjsonlogger import jsonlogger +from pythonjsonlogger.json import JsonFormatter class _StdoutStream: @@ -116,7 +116,7 @@ def validate_attrs( ) -class CustomJsonFormatter(jsonlogger.JsonFormatter): +class CustomJsonFormatter(JsonFormatter): def add_fields(self, log_record, record, message_dict): super(CustomJsonFormatter, self).add_fields(log_record, record, message_dict) if not log_record.get("timestamp"): diff --git a/pyproject.toml b/pyproject.toml index 4037c40..3d936cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,3 +96,8 @@ warn_redundant_casts = true warn_return_any = true warn_unused_configs = true warn_unused_ignores = true + +[tool.coverage.run] +omit = [ + "test/*", +] diff --git a/test/configuration/test_connector_config_schema_generator.py b/test/configuration/test_connector_config_schema_generator.py new file mode 100644 index 0000000..7dad911 --- /dev/null +++ b/test/configuration/test_connector_config_schema_generator.py @@ -0,0 +1,106 @@ +import unittest +from unittest.mock import MagicMock + +from pyoaev.configuration.connector_config_schema_generator import ( + ConnectorConfigSchemaGenerator, +) + + +class TestConnectorConfigSchemaGenerator(unittest.TestCase): + def test_dereference_schema_resolves_internal_refs(self): + schema = { + "$defs": { + "Item": { + "type": "object", + "properties": {"value": {"type": "string"}}, + } + }, + "type": "object", + "properties": { + "item": {"$ref": "#/$defs/Item"}, + "items": {"type": "array", "items": [{"$ref": "#/$defs/Item"}]}, + }, + } + + resolved = ConnectorConfigSchemaGenerator.dereference_schema(schema) + + self.assertEqual(resolved["properties"]["item"]["type"], "object") + self.assertIn("value", resolved["properties"]["item"]["properties"]) + self.assertEqual( + resolved["properties"]["items"]["items"][0]["properties"]["value"]["type"], + "string", + ) + + def test_dereference_schema_rejects_unsupported_refs(self): + with self.assertRaises(ValueError): + ConnectorConfigSchemaGenerator.dereference_schema( + {"$ref": "external://schema"} + ) + + def test_flatten_config_loader_schema_and_filter_schema(self): + root_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "config.schema.json", + "additionalProperties": False, + "properties": { + "connector": { + "properties": { + "name": {"type": "string", "title": "Name"}, + "id": {"type": "string"}, + }, + "required": ["name"], + } + }, + } + + flattened = ConnectorConfigSchemaGenerator.flatten_config_loader_schema( + root_schema + ) + flattened["properties"]["CONNECTOR_ID"] = {"type": "string"} + flattened["required"].append("CONNECTOR_ID") + + filtered = ConnectorConfigSchemaGenerator.filter_schema(flattened) + + self.assertEqual(filtered["additionalProperties"], False) + self.assertIn("CONNECTOR_NAME", filtered["properties"]) + self.assertNotIn("title", filtered["properties"]["CONNECTOR_NAME"]) + self.assertIn("CONNECTOR_NAME", filtered["required"]) + self.assertNotIn("CONNECTOR_ID", filtered["properties"]) + self.assertNotIn("CONNECTOR_ID", filtered["required"]) + + def test_flatten_config_loader_schema_defaults_additional_properties_to_true(self): + root_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "config.schema.json", + "properties": {"app": {"properties": {}, "required": []}}, + } + + flattened = ConnectorConfigSchemaGenerator.flatten_config_loader_schema( + root_schema + ) + + self.assertTrue(flattened["additionalProperties"]) + + def test_nullable_schema_returns_null_when_inner_schema_is_null(self): + generator = ConnectorConfigSchemaGenerator(by_alias=True) + generator.generate_inner = MagicMock(return_value={"type": "null"}) + + result = generator.nullable_schema( + {"type": "nullable", "schema": {"type": "str"}} + ) + + self.assertEqual(result, {"type": "null"}) + + def test_nullable_schema_returns_inner_schema_when_not_null(self): + generator = ConnectorConfigSchemaGenerator(by_alias=True) + generator.generate_inner = MagicMock(return_value={"type": "string"}) + + result = generator.nullable_schema( + {"type": "nullable", "schema": {"type": "str"}} + ) + + self.assertEqual(result, {"type": "string"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_utils.py b/test/test_utils.py new file mode 100644 index 0000000..13fab9a --- /dev/null +++ b/test/test_utils.py @@ -0,0 +1,259 @@ +import dataclasses +import importlib +import json +import logging +import unittest +import warnings +from typing import Any, cast +from unittest.mock import MagicMock, patch + +from pythonjsonlogger.json import JsonFormatter + +import pyoaev.utils as module + + +@dataclasses.dataclass +class _SampleData: + value: int + + +class TestUtils(unittest.TestCase): + def test_custom_json_formatter_inherits_non_deprecated_formatter(self): + self.assertTrue(issubclass(module.CustomJsonFormatter, JsonFormatter)) + + def test_reloading_utils_does_not_raise_jsonlogger_deprecation_warning(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.reload(module) + + deprecation_messages = [ + str(warning.message) + for warning in caught + if issubclass(warning.category, DeprecationWarning) + ] + self.assertFalse( + any( + "pythonjsonlogger.jsonlogger has been moved" in message + for message in deprecation_messages + ) + ) + + def test_get_content_type(self): + self.assertEqual( + module.get_content_type("application/json; charset=utf-8"), + "application/json", + ) + + def test_copy_dict_flattens_nested_dict(self): + destination = {} + module.copy_dict( + src={"a": 1, "meta": {"x": "y", "n": 2}}, + dest=destination, + ) + self.assertEqual(destination, {"a": 1, "meta[x]": "y", "meta[n]": 2}) + + def test_remove_none_from_dict(self): + self.assertEqual(module.remove_none_from_dict({"a": 1, "b": None}), {"a": 1}) + + def test_encoded_id_from_existing_instance_returns_same_object(self): + encoded_id = module.EncodedId("with space") + self.assertIs(module.EncodedId(encoded_id), encoded_id) + + def test_encoded_id_encodes_string_and_keeps_int(self): + self.assertEqual(module.EncodedId("a/b c"), "a%2Fb%20c") + self.assertEqual(module.EncodedId(42), "42") + + def test_encoded_id_rejects_unsupported_type(self): + with self.assertRaises(TypeError): + module.EncodedId(cast(Any, ["bad"])) + + def test_enhanced_json_encoder_serializes_dataclasses(self): + self.assertEqual( + json.dumps(_SampleData(value=3), cls=module.EnhancedJSONEncoder), + '{"value": 3}', + ) + + def test_required_optional_required_attribute_missing(self): + rules = module.RequiredOptional(required=("name",)) + with self.assertRaises(AttributeError): + rules.validate_attrs(data={}) + + def test_required_optional_excludes_required_attribute(self): + rules = module.RequiredOptional(required=("name",)) + rules.validate_attrs(data={}, excludes=["name"]) + + def test_required_optional_exclusive_allows_only_one_key(self): + rules = module.RequiredOptional(exclusive=("a", "b")) + with self.assertRaises(AttributeError): + rules.validate_attrs(data={"a": 1, "b": 2}) + + def test_required_optional_exclusive_requires_one_key(self): + rules = module.RequiredOptional(exclusive=("a", "b")) + with self.assertRaises(AttributeError): + rules.validate_attrs(data={}) + + def test_required_optional_exclusive_with_single_key_is_valid(self): + rules = module.RequiredOptional(exclusive=("a", "b")) + rules.validate_attrs(data={"a": 1}) + + def test_response_content_returns_iterator_when_requested(self): + response = MagicMock() + response.iter_content.return_value = iter([b"a", b"b"]) + + iterator = module.response_content( + response, + streamed=False, + action=None, + chunk_size=10, + iterator=True, + ) + + self.assertEqual(list(iterator), [b"a", b"b"]) + + def test_response_content_returns_raw_content_when_not_streamed(self): + response = MagicMock() + response.content = b"payload" + + data = module.response_content( + response, + streamed=False, + action=None, + chunk_size=10, + iterator=False, + ) + + self.assertEqual(data, b"payload") + + def test_response_content_streamed_uses_action_for_non_empty_chunks(self): + response = MagicMock() + response.iter_content.return_value = [b"one", b"", b"two"] + action = MagicMock() + + returned = module.response_content( + response, + streamed=True, + action=action, + chunk_size=10, + iterator=False, + ) + + self.assertIsNone(returned) + action.assert_any_call(b"one") + action.assert_any_call(b"two") + self.assertEqual(action.call_count, 2) + + def test_response_content_streamed_defaults_to_stdout_stream(self): + response = MagicMock() + response.iter_content.return_value = [b"visible"] + + with patch("builtins.print") as mock_print: + module.response_content( + response, + streamed=True, + action=None, + chunk_size=10, + iterator=False, + ) + + mock_print.assert_called_once_with(b"visible") + + def test_custom_json_formatter_add_fields_sets_timestamp_and_level(self): + formatter = module.CustomJsonFormatter("%(message)s") + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="hello", + args=(), + exc_info=None, + ) + log_record = {} + + formatter.add_fields(log_record, record, {}) + + self.assertIn("timestamp", log_record) + self.assertEqual(log_record["level"], "INFO") + + def test_setup_logging_config_json_logging_true_uses_custom_formatter(self): + with patch("pyoaev.utils.logging.basicConfig") as mock_basic_config: + module.setup_logging_config(logging.INFO, json_logging=True) + + kwargs = mock_basic_config.call_args.kwargs + self.assertEqual(kwargs["level"], logging.INFO) + self.assertIn("handlers", kwargs) + self.assertIsInstance( + kwargs["handlers"][0].formatter, module.CustomJsonFormatter + ) + + def test_setup_logging_config_json_logging_false_calls_basic_config(self): + with patch("pyoaev.utils.logging.basicConfig") as mock_basic_config: + module.setup_logging_config(logging.WARNING, json_logging=False) + + mock_basic_config.assert_called_once_with(level=logging.WARNING) + + def test_app_logger_methods_delegate_to_local_logger(self): + with patch("pyoaev.utils.setup_logging_config"): + app_logger = module.AppLogger(logging.INFO) + app_logger.local_logger = MagicMock() + + app_logger.debug("d", {"x": 1}) + app_logger.info("i") + app_logger.warning("w") + app_logger.error("e") + + self.assertTrue(app_logger.local_logger.debug.called) + self.assertTrue(app_logger.local_logger.info.called) + self.assertTrue(app_logger.local_logger.warning.called) + self.assertTrue(app_logger.local_logger.error.called) + + def test_logger_helper_returns_app_logger(self): + with patch("pyoaev.utils.setup_logging_config"): + helper = module.logger(logging.INFO) + self.assertIsInstance(helper, module.AppLogger) + + def test_pingalive_ping_uses_injector_branch(self): + api = MagicMock() + logger = MagicMock() + ping_alive = module.PingAlive( + api=api, config={"id": 1}, logger=logger, ping_type="injector" + ) + ping_alive.exit_event.is_set = MagicMock(side_effect=[False, True]) + ping_alive.exit_event.wait = MagicMock() + + ping_alive.ping() + + api.injector.create.assert_called_once_with({"id": 1}, False) + ping_alive.exit_event.wait.assert_called_once_with(40) + + def test_pingalive_ping_uses_collector_branch_and_logs_errors(self): + api = MagicMock() + api.collector.create.side_effect = Exception("boom") + logger = MagicMock() + ping_alive = module.PingAlive( + api=api, config={}, logger=logger, ping_type="collector" + ) + ping_alive.exit_event.is_set = MagicMock(side_effect=[False, True]) + ping_alive.exit_event.wait = MagicMock() + + ping_alive.ping() + + logger.error.assert_called_once() + ping_alive.exit_event.wait.assert_called_once_with(40) + + def test_pingalive_run_and_stop(self): + ping_alive = module.PingAlive( + api=MagicMock(), config={}, logger=MagicMock(), ping_type="collector" + ) + ping_alive.ping = MagicMock() + + ping_alive.run() + ping_alive.stop() + + ping_alive.logger.info.assert_any_call("Starting PingAlive thread") + ping_alive.logger.info.assert_any_call("Preparing PingAlive for clean shutdown") + self.assertTrue(ping_alive.exit_event.is_set()) + + +if __name__ == "__main__": + unittest.main() From 8dfc6ff9b8546c404397e00893b110997d8e1775 Mon Sep 17 00:00:00 2001 From: Nicolas Carenton Date: Tue, 5 May 2026 15:34:31 +0200 Subject: [PATCH 3/4] [client-python] chore(docs): fix readthedocs (#69) --- .readthedocs.yml | 16 +--------- README.md | 2 +- docs/_static/.gitkeep | 0 docs/conf.py | 69 ++++++++++++++++++------------------------- docs/index.rst | 5 ---- docs/requirements.txt | 7 +++-- 6 files changed, 35 insertions(+), 64 deletions(-) create mode 100644 docs/_static/.gitkeep diff --git a/.readthedocs.yml b/.readthedocs.yml index 5b8a44a..2a84f3c 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,9 +1,4 @@ --- -# .readthedocs.yml -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - -# Required version: 2 build: @@ -11,19 +6,10 @@ build: tools: python: "3.12" -# Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py -# Build documentation with MkDocs -#mkdocs: -# configuration: mkdocs.yml - -# Optionally build your docs in additional formats such as PDF and ePub -formats: all - -# Optionally set the version of Python and requirements required to build your docs python: install: - - requirements: requirements.txt - requirements: docs/requirements.txt + - path: . diff --git a/README.md b/README.md index 3aa6654..72baab3 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ To learn about how to use the OpenAEV Python client and read some examples and c ### API reference -To learn about the methods available for executing queries and retrieving their answers, refer to [the client API Reference](https://openaev-client-for-python.readthedocs.io/en/latest/pyoaev/pyoaev.html). +To learn about the methods available for executing queries and retrieving their answers, refer to [the client API Reference](https://openaev-client-for-python.readthedocs.io/en/latest/autoapi/index.html). ## Tests diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/conf.py b/docs/conf.py index 80a4a18..748f559 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,65 +1,54 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# import os import sys sys.path.insert(0, os.path.abspath("..")) - -# -- Project information ----------------------------------------------------- - project = "OpenAEV client for Python" copyright = "2024, Filigran" author = "OpenAEV Project" - -# The full version, including alpha/beta/rc tags release = "1.10.1" master_doc = "index" - -autoapi_modules = {"pyoaev": {"prune": True}} - pygments_style = "sphinx" -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.inheritance_diagram", - "autoapi.sphinx", + "autoapi.extension", "sphinx_autodoc_typehints", ] -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] +autoapi_dirs = ["../pyoaev"] +autoapi_options = [ + "members", + "undoc-members", + "private-members", + "show-inheritance", +] -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +autodoc_inherit_docstrings = False +# Inherited docstrings from stdlib base classes are noisy — suppress them +_INHERITED_DOCSTRING_MARKERS = [ + "Create a collection of name/value pairs.", # enum.Enum + "str(object='') -> str", # str +] -# -- Options for HTML output ------------------------------------------------- -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "sphinx_rtd_theme" +def _suppress_inherited_docstring(app, what, name, obj, options, lines): + """Remove inherited stdlib docstrings from generated API docs.""" + if what == "class" and lines: + joined = "\n".join(lines) + if any(marker in joined for marker in _INHERITED_DOCSTRING_MARKERS): + lines.clear() + + +def setup(app): + app.connect("autodoc-process-docstring", _suppress_inherited_docstring) -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] diff --git a/docs/index.rst b/docs/index.rst index 5b66af8..a9d07c5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,7 @@ OpenAEV client for Python ========================= - The pyoaev library is designed to help OpenAEV users and developers to interact with the OpenAEV platform API. - The Python library requires Python >= 3. .. toctree:: @@ -11,12 +9,9 @@ The Python library requires Python >= 3. :caption: Contents: client_usage/getting_started.rst - pyoaev/pyoaev - Indices and tables ================== - * :ref:`genindex` * :ref:`modindex` * :ref:`search` diff --git a/docs/requirements.txt b/docs/requirements.txt index 7b94d45..622bdef 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ -autoapi==2.0.1 -sphinx==8.2.3 +sphinx==9.1.0 +sphinx-autoapi==3.4.0 +astroid==3.3.8 sphinx-autodoc-typehints==3.2.0 -sphinx_rtd_theme==3.0.2 +sphinx_rtd_theme==3.1.0 From 05ceaabe11120027ecfabafc971f7563cd262638 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 14:09:40 +0000 Subject: [PATCH 4/4] [client-python] chore(deps): update dependency black to >=25.12.0,<25.13.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3d936cb..183f931 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "black (>=25.11.0,<25.12.0)", + "black (>=25.12.0,<25.13.0)", "build (>=1.3.0,<1.4.0)", "isort (>=6.1.0,<6.2.0)", "types-pytz (>=2025.2.0.20250326,<2025.3.0.0)",