fairagro-middleware-shared 12.2.0__tar.gz → 12.2.2.dev37__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/PKG-INFO +1 -1
  2. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/common/rocrate.py +19 -10
  3. fairagro_middleware_shared-12.2.2.dev37/src/middleware/shared/config/logging.py +91 -0
  4. fairagro_middleware_shared-12.2.2.dev37/src/middleware/shared/json_types.py +12 -0
  5. fairagro_middleware_shared-12.2.2.dev37/src/middleware/shared/security/__init__.py +5 -0
  6. fairagro_middleware_shared-12.2.2.dev37/src/middleware/shared/security/url_redact.py +13 -0
  7. fairagro_middleware_shared-12.2.2.dev37/tests/unit/test_url_redact.py +84 -0
  8. fairagro_middleware_shared-12.2.0/src/middleware/shared/config/logging.py +0 -25
  9. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/.gitignore +0 -0
  10. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/README.md +0 -0
  11. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/pyproject.toml +0 -0
  12. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/__init__.py +0 -0
  13. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/__init__.py +0 -0
  14. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/common/__init__.py +0 -0
  15. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/common/models.py +0 -0
  16. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/py.typed +0 -0
  17. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/v1/__init__.py +0 -0
  18. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/v1/models.py +0 -0
  19. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/v2/__init__.py +0 -0
  20. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/v2/models.py +0 -0
  21. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/v3/__init__.py +0 -0
  22. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/api_models/v3/models.py +0 -0
  23. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/config/__init__.py +0 -0
  24. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/config/config_base.py +0 -0
  25. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/config/config_wrapper.py +0 -0
  26. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/py.typed +0 -0
  27. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/report/__init__.py +0 -0
  28. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/report/formats/__init__.py +0 -0
  29. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/report/formats/base.py +0 -0
  30. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/report/formats/jsonld.py +0 -0
  31. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/report/model.py +0 -0
  32. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/src/middleware/shared/tracing.py +0 -0
  33. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/tests/unit/test_api_models.py +0 -0
  34. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/tests/unit/test_config_base.py +0 -0
  35. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/tests/unit/test_config_wrapper.py +0 -0
  36. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/tests/unit/test_harvest_report.py +0 -0
  37. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/tests/unit/test_logging.py +0 -0
  38. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/tests/unit/test_rocrate.py +0 -0
  39. {fairagro_middleware_shared-12.2.0 → fairagro_middleware_shared-12.2.2.dev37}/tests/unit/test_tracing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fairagro-middleware-shared
3
- Version: 12.2.0
3
+ Version: 12.2.2.dev37
4
4
  Summary: The FAIRagro advanced middleware shared components
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: opentelemetry-api>=1.26.0
@@ -1,20 +1,22 @@
1
1
  """RO-Crate payload validation for ARC upload requests."""
2
2
 
3
3
  from functools import cached_property
4
- from typing import Annotated, Any, Self
4
+ from typing import Annotated, Self, cast
5
5
 
6
6
  from pydantic import BaseModel, ConfigDict, Field, model_validator
7
7
 
8
+ from middleware.shared.json_types import JsonValue, RoCrateContent, RoCrateGraphNode
8
9
 
9
- def _root_dataset_entity(graph: list[dict[str, Any]]) -> dict[str, Any] | None:
10
+
11
+ def _root_dataset_entity(graph: list[RoCrateGraphNode]) -> RoCrateGraphNode | None:
10
12
  """Return the RO-Crate root data entity (``@id``: ``./``) from ``@graph``."""
11
13
  for item in graph:
12
- if isinstance(item, dict) and item.get("@id") == "./":
14
+ if item.get("@id") == "./":
13
15
  return item
14
16
  return None
15
17
 
16
18
 
17
- def _normalize_text_field(value: object) -> str | None:
19
+ def _normalize_text_field(value: JsonValue | None) -> str | None:
18
20
  if isinstance(value, list):
19
21
  value = value[0] if value else None
20
22
  if isinstance(value, dict):
@@ -25,7 +27,7 @@ def _normalize_text_field(value: object) -> str | None:
25
27
  return text or None
26
28
 
27
29
 
28
- def _extract_identifier(root: dict[str, Any]) -> str:
30
+ def _extract_identifier(root: RoCrateGraphNode) -> str:
29
31
  """Extract and normalize the required ``identifier`` from the root data entity."""
30
32
  identifier = _normalize_text_field(root.get("identifier"))
31
33
  if not identifier:
@@ -34,12 +36,12 @@ def _extract_identifier(root: dict[str, Any]) -> str:
34
36
  return identifier
35
37
 
36
38
 
37
- def _extract_optional_text(root: dict[str, Any], field: str) -> str | None:
39
+ def _extract_optional_text(root: RoCrateGraphNode, field: str) -> str | None:
38
40
  """Extract an optional text field from the root data entity."""
39
41
  return _normalize_text_field(root.get(field))
40
42
 
41
43
 
42
- def validate_root_dataset(graph: list[dict[str, Any]]) -> dict[str, Any]:
44
+ def validate_root_dataset(graph: list[RoCrateGraphNode]) -> RoCrateGraphNode:
43
45
  """Validate ``@graph`` contains a root data entity with a non-empty ``identifier``."""
44
46
  root = _root_dataset_entity(graph)
45
47
  if root is None:
@@ -54,8 +56,8 @@ class RoCratePayload(BaseModel):
54
56
 
55
57
  model_config = ConfigDict(populate_by_name=True, extra="forbid")
56
58
 
57
- context: Annotated[Any, Field(alias="@context")]
58
- graph: Annotated[list[dict[str, Any]], Field(alias="@graph", min_length=1)]
59
+ context: Annotated[JsonValue, Field(alias="@context")]
60
+ graph: Annotated[list[RoCrateGraphNode], Field(alias="@graph", min_length=1)]
59
61
 
60
62
  @model_validator(mode="after")
61
63
  def validate_root_dataset_fields(self) -> Self:
@@ -64,7 +66,7 @@ class RoCratePayload(BaseModel):
64
66
  return self
65
67
 
66
68
  @cached_property
67
- def _root(self) -> dict[str, Any]:
69
+ def _root(self) -> RoCrateGraphNode:
68
70
  """Root data entity dict (validated during model construction)."""
69
71
  root = _root_dataset_entity(self.graph)
70
72
  if root is None:
@@ -86,3 +88,10 @@ class RoCratePayload(BaseModel):
86
88
  def description(self) -> str | None:
87
89
  """Optional RO-Crate ``description`` from the root data entity."""
88
90
  return _extract_optional_text(self._root, "description")
91
+
92
+ def to_content(self) -> RoCrateContent:
93
+ """Return the wire-format RO-Crate document."""
94
+ return {
95
+ "@context": self.context,
96
+ "@graph": cast(JsonValue, self.graph),
97
+ }
@@ -0,0 +1,91 @@
1
+ """Logging configuration module.
2
+
3
+ This module provides functionality to configure logging levels for all handlers
4
+ and the root logger across the application, and installs URL-userinfo redaction
5
+ so oauth2/Git credentials do not appear in formatted log output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from middleware.shared.config.config_base import LogLevel
13
+ from middleware.shared.security.url_redact import redact_url_userinfo
14
+
15
+
16
+ class RedactingFormatter(logging.Formatter):
17
+ """Wrap another formatter and redact URL userinfo in the final log line."""
18
+
19
+ def __init__(self, wrapped: logging.Formatter | None = None) -> None:
20
+ """Initialize with an optional underlying formatter.
21
+
22
+ Copies the wrapped ``_fmt`` / ``datefmt`` into this instance so
23
+ ``handler.formatter._fmt`` still reflects the real log layout.
24
+ """
25
+ self._wrapped = wrapped if wrapped is not None else logging.Formatter()
26
+ super().__init__(
27
+ fmt=getattr(self._wrapped, "_fmt", None),
28
+ datefmt=getattr(self._wrapped, "datefmt", None),
29
+ )
30
+
31
+ def format(self, record: logging.LogRecord) -> str:
32
+ """Format the record, then strip URL credentials from the result."""
33
+ return redact_url_userinfo(self._wrapped.format(record))
34
+
35
+
36
+ class _RedactingLogFilter(logging.Filter):
37
+ """Scrub URL userinfo from record message/args before handlers format."""
38
+
39
+ def filter(self, record: logging.LogRecord) -> bool: # noqa: PLR6301
40
+ """Redact ``record.msg`` / string ``args`` in place."""
41
+ if isinstance(record.msg, str):
42
+ record.msg = redact_url_userinfo(record.msg)
43
+ if isinstance(record.args, dict):
44
+ record.args = {
45
+ key: redact_url_userinfo(value) if isinstance(value, str) else value
46
+ for key, value in record.args.items()
47
+ }
48
+ elif isinstance(record.args, tuple):
49
+ record.args = tuple(redact_url_userinfo(arg) if isinstance(arg, str) else arg for arg in record.args)
50
+ return True
51
+
52
+
53
+ def install_url_userinfo_redaction(logger: logging.Logger | None = None) -> None:
54
+ """Ensure handlers redact ``https://userinfo@`` in every formatted log line.
55
+
56
+ Safe to call repeatedly; handlers that already use :class:`RedactingFormatter`
57
+ are left unchanged. Also attaches a logger-level filter so message/args are
58
+ scrubbed when possible.
59
+ """
60
+ target = logging.getLogger() if logger is None else logger
61
+ if not any(isinstance(f, _RedactingLogFilter) for f in target.filters):
62
+ target.addFilter(_RedactingLogFilter())
63
+ for handler in target.handlers:
64
+ if not isinstance(handler, logging.Handler):
65
+ # Skip test doubles / non-stdlib handlers without a real formatter API.
66
+ continue
67
+ if not any(isinstance(f, _RedactingLogFilter) for f in handler.filters):
68
+ handler.addFilter(_RedactingLogFilter())
69
+ current = handler.formatter
70
+ if isinstance(current, RedactingFormatter):
71
+ continue
72
+ if current is not None and not isinstance(current, logging.Formatter):
73
+ continue
74
+ handler.setFormatter(RedactingFormatter(current))
75
+
76
+
77
+ def configure_logging(level: LogLevel) -> None:
78
+ """Configure logging level for all handlers.
79
+
80
+ Args:
81
+ level: Logging level to set for all handlers and root logger.
82
+ """
83
+ root = logging.getLogger()
84
+ if root.handlers:
85
+ # vorhandene Handler neu konfigurieren
86
+ for h in root.handlers:
87
+ h.setLevel(level)
88
+ root.setLevel(level)
89
+ else:
90
+ logging.basicConfig(level=level)
91
+ install_url_userinfo_redaction(root)
@@ -0,0 +1,12 @@
1
+ """JSON value types for wire-format documents (RO-Crate, CouchDB, catalog files)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ type JsonPrimitive = str | int | float | bool | None
6
+ type JsonValue = JsonPrimitive | list[JsonValue] | dict[str, JsonValue]
7
+
8
+ type JsonObject = dict[str, JsonValue]
9
+ type RoCrateContent = JsonObject
10
+ type RoCrateGraphNode = JsonObject
11
+ type CatalogDatasetRecord = JsonObject
12
+ type CouchDbDocument = JsonObject
@@ -0,0 +1,5 @@
1
+ """Security helpers shared across middleware components."""
2
+
3
+ from middleware.shared.security.url_redact import redact_url_userinfo
4
+
5
+ __all__ = ["redact_url_userinfo"]
@@ -0,0 +1,13 @@
1
+ """Redact credentials embedded in URL userinfo (e.g. oauth2 tokens in Git remotes)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # http(s)://user:pass@host or http(s)://token@host — used by Git HTTPS remotes.
8
+ _URL_USERINFO = re.compile(r"(https?://)([^/\s\"'<>]+)@", re.IGNORECASE)
9
+
10
+
11
+ def redact_url_userinfo(text: str) -> str:
12
+ """Replace URL userinfo with ``***@`` so tokens never appear in logs/events."""
13
+ return _URL_USERINFO.sub(r"\1***@", text)
@@ -0,0 +1,84 @@
1
+ """Tests for URL userinfo redaction."""
2
+
3
+ import logging
4
+
5
+ from middleware.shared.config.logging import RedactingFormatter, install_url_userinfo_redaction
6
+ from middleware.shared.security.url_redact import redact_url_userinfo
7
+
8
+
9
+ def test_redact_url_userinfo_oauth2_token() -> None:
10
+ """oauth2 tokens in https URLs are replaced with ***."""
11
+ raw = "push failed: https://oauth2:secret-token@gitlab.example.com/group/repo.git"
12
+ assert redact_url_userinfo(raw) == "push failed: https://***@gitlab.example.com/group/repo.git"
13
+ assert "secret-token" not in redact_url_userinfo(raw)
14
+
15
+
16
+ def test_redacting_formatter_preserves_wrapped_fmt() -> None:
17
+ """Outer formatter exposes the same _fmt as the wrapped layout."""
18
+ inner = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
19
+ outer = RedactingFormatter(inner)
20
+ assert outer._fmt == inner._fmt # noqa: SLF001
21
+
22
+
23
+ def test_redacting_formatter_covers_message_and_exception_text() -> None:
24
+ """Formatted log lines (including traceback text) are redacted."""
25
+ logger = logging.getLogger("test_url_redact_formatter")
26
+ logger.handlers.clear()
27
+ logger.propagate = False
28
+ stream = logging.StreamHandler()
29
+ stream.setFormatter(RedactingFormatter(logging.Formatter("%(message)s")))
30
+ logger.addHandler(stream)
31
+
32
+ record = logger.makeRecord(
33
+ logger.name,
34
+ logging.ERROR,
35
+ __file__,
36
+ 1,
37
+ "failed https://oauth2:secret-token@host/repo.git",
38
+ (),
39
+ None,
40
+ )
41
+ assert "secret-token" not in stream.format(record)
42
+
43
+
44
+ def test_install_url_userinfo_redaction_is_idempotent() -> None:
45
+ """Wrapping handlers twice must not nest forever."""
46
+ root = logging.getLogger()
47
+ handler = logging.StreamHandler()
48
+ handler.setFormatter(logging.Formatter("%(message)s"))
49
+ root.addHandler(handler)
50
+ try:
51
+ install_url_userinfo_redaction()
52
+ install_url_userinfo_redaction()
53
+ assert isinstance(handler.formatter, RedactingFormatter)
54
+ assert not isinstance(handler.formatter._wrapped, RedactingFormatter) # noqa: SLF001
55
+ finally:
56
+ root.removeHandler(handler)
57
+
58
+
59
+ def test_install_url_userinfo_redaction_rewraps_replaced_formatter() -> None:
60
+ """If a handler formatter is replaced after install, the next install re-wraps."""
61
+ root = logging.getLogger()
62
+ handler = logging.StreamHandler()
63
+ handler.setFormatter(logging.Formatter("%(message)s"))
64
+ root.addHandler(handler)
65
+ try:
66
+ install_url_userinfo_redaction()
67
+ assert isinstance(handler.formatter, RedactingFormatter)
68
+ handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
69
+ install_url_userinfo_redaction()
70
+ assert isinstance(handler.formatter, RedactingFormatter)
71
+ assert handler.formatter._fmt == "%(levelname)s %(message)s" # noqa: SLF001
72
+ assert "secret-token" not in handler.format(
73
+ logging.LogRecord(
74
+ "test",
75
+ logging.ERROR,
76
+ __file__,
77
+ 1,
78
+ "leak https://oauth2:secret-token@host/r.git",
79
+ (),
80
+ None,
81
+ )
82
+ )
83
+ finally:
84
+ root.removeHandler(handler)
@@ -1,25 +0,0 @@
1
- """Logging configuration module.
2
-
3
- This module provides functionality to configure logging levels for all handlers
4
- and the root logger across the application.
5
- """
6
-
7
- import logging
8
-
9
- from middleware.shared.config.config_base import LogLevel
10
-
11
-
12
- def configure_logging(level: LogLevel) -> None:
13
- """Configure logging level for all handlers.
14
-
15
- Args:
16
- level: Logging level to set for all handlers and root logger.
17
- """
18
- root = logging.getLogger()
19
- if root.handlers:
20
- # vorhandene Handler neu konfigurieren
21
- for h in root.handlers:
22
- h.setLevel(level)
23
- root.setLevel(level)
24
- else:
25
- logging.basicConfig(level=level)