otdf-python 0.10.2__py3-none-any.whl → 0.11.0__py3-none-any.whl

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 (52) hide show
  1. otdf_python/__init__.py +24 -0
  2. otdf_python/__main__.py +11 -0
  3. otdf_python/address_normalizer.py +82 -0
  4. otdf_python/aesgcm.py +63 -0
  5. otdf_python/assertion_config.py +105 -0
  6. otdf_python/asym_crypto.py +194 -0
  7. otdf_python/auth_headers.py +35 -0
  8. otdf_python/autoconfigure_utils.py +127 -0
  9. otdf_python/cli.py +693 -0
  10. otdf_python/collection_store.py +53 -0
  11. otdf_python/collection_store_impl.py +27 -0
  12. otdf_python/config.py +82 -0
  13. otdf_python/connect_client.py +1 -0
  14. otdf_python/constants.py +3 -0
  15. otdf_python/crypto_utils.py +82 -0
  16. otdf_python/dpop.py +79 -0
  17. otdf_python/ecc_constants.py +174 -0
  18. otdf_python/ecc_mode.py +88 -0
  19. otdf_python/ecdh.py +310 -0
  20. otdf_python/eckeypair.py +80 -0
  21. otdf_python/header.py +186 -0
  22. otdf_python/invalid_zip_exception.py +12 -0
  23. otdf_python/kas_allowlist.py +182 -0
  24. otdf_python/kas_client.py +752 -0
  25. otdf_python/kas_connect_rpc_client.py +251 -0
  26. otdf_python/kas_info.py +27 -0
  27. otdf_python/kas_key_cache.py +53 -0
  28. otdf_python/key_type.py +35 -0
  29. otdf_python/key_type_constants.py +37 -0
  30. otdf_python/manifest.py +239 -0
  31. otdf_python/nanotdf.py +863 -0
  32. otdf_python/nanotdf_ecdsa_struct.py +127 -0
  33. otdf_python/nanotdf_type.py +55 -0
  34. otdf_python/policy_binding_serializer.py +42 -0
  35. otdf_python/policy_info.py +61 -0
  36. otdf_python/policy_object.py +30 -0
  37. otdf_python/policy_stub.py +4 -0
  38. otdf_python/resource_locator.py +181 -0
  39. otdf_python/sdk.py +446 -0
  40. otdf_python/sdk_builder.py +555 -0
  41. otdf_python/sdk_exceptions.py +26 -0
  42. otdf_python/symmetric_and_payload_config.py +36 -0
  43. otdf_python/tdf.py +533 -0
  44. otdf_python/tdf_reader.py +151 -0
  45. otdf_python/tdf_writer.py +28 -0
  46. otdf_python/token_source.py +35 -0
  47. otdf_python/version.py +62 -0
  48. otdf_python/zip_reader.py +55 -0
  49. otdf_python/zip_writer.py +81 -0
  50. {otdf_python-0.10.2.dist-info → otdf_python-0.11.0.dist-info}/METADATA +1 -1
  51. {otdf_python-0.10.2.dist-info → otdf_python-0.11.0.dist-info}/RECORD +52 -3
  52. {otdf_python-0.10.2.dist-info → otdf_python-0.11.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,24 @@
1
+ """OpenTDF Python SDK.
2
+
3
+ A Python implementation of the OpenTDF SDK for working with Trusted Data Format (TDF) files.
4
+ Provides both programmatic APIs and command-line interface for encryption and decryption.
5
+ """
6
+
7
+ from .cli import main as cli_main
8
+ from .config import KASInfo, NanoTDFConfig, TDFConfig
9
+ from .sdk import SDK
10
+ from .sdk_builder import SDKBuilder
11
+
12
+ __all__ = [
13
+ "SDK",
14
+ "KASInfo",
15
+ "NanoTDFConfig",
16
+ "SDKBuilder",
17
+ "TDFConfig",
18
+ "cli_main",
19
+ ]
20
+
21
+
22
+ def main() -> None:
23
+ """Entry point for the CLI."""
24
+ cli_main()
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env python3
2
+ """Main entry point for running otdf_python as a module.
3
+
4
+ This allows the package to be run with `python -m otdf_python` and properly
5
+ handles the CLI interface without import conflicts.
6
+ """
7
+
8
+ from .cli import main
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -0,0 +1,82 @@
1
+ """Address normalization utilities for OpenTDF."""
2
+
3
+ import logging
4
+ import re
5
+ from urllib.parse import urlparse
6
+
7
+ from .sdk_exceptions import SDKException
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def normalize_address(url_string: str, use_plaintext: bool) -> str:
13
+ """Normalize a URL address to ensure it has the correct scheme and port.
14
+
15
+ Args:
16
+ url_string: The URL string to normalize
17
+ use_plaintext: If True, use http scheme, otherwise use https
18
+
19
+ Returns:
20
+ The normalized URL string
21
+
22
+ Raises:
23
+ SDKException: If there's an error parsing or creating the URL
24
+
25
+ """
26
+ scheme = "http" if use_plaintext else "https"
27
+
28
+ # Check if we have a host:port format without scheme (with non-digit port)
29
+ host_port_pattern = re.match(r"^([^/:]+):([^/]+)$", url_string)
30
+ if host_port_pattern:
31
+ host = host_port_pattern.group(1)
32
+ port_str = host_port_pattern.group(2)
33
+ try:
34
+ port = int(port_str)
35
+ except ValueError as err:
36
+ raise SDKException(f"Invalid port in URL [{url_string}]") from err
37
+
38
+ normalized_url = f"{scheme}://{host}:{port}"
39
+ logger.debug(f"normalized url [{url_string}] to [{normalized_url}]")
40
+ return normalized_url
41
+
42
+ try:
43
+ # Check if we just have a hostname without scheme and port
44
+ if "://" not in url_string and "/" not in url_string and ":" not in url_string:
45
+ port = 80 if use_plaintext else 443
46
+ normalized_url = f"{scheme}://{url_string}:{port}"
47
+ logger.debug(f"normalized url [{url_string}] to [{normalized_url}]")
48
+ return normalized_url
49
+
50
+ # Parse the URL
51
+ parsed_url = urlparse(url_string)
52
+
53
+ # If no scheme, add one
54
+ if not parsed_url.scheme:
55
+ url_string = f"{scheme}://{url_string}"
56
+ parsed_url = urlparse(url_string)
57
+
58
+ # Extract host and port
59
+ host = parsed_url.netloc.split(":")[0] if parsed_url.netloc else parsed_url.path
60
+
61
+ # If there's a port in the URL, try to extract it
62
+ port = None
63
+ if ":" in parsed_url.netloc:
64
+ _, port_str = parsed_url.netloc.split(":", 1)
65
+ try:
66
+ port = int(port_str)
67
+ except ValueError as err:
68
+ raise SDKException(f"Invalid port in URL [{url_string}]") from err
69
+
70
+ # If no port was found or extracted, use the default
71
+ if port is None:
72
+ port = 80 if use_plaintext else 443
73
+
74
+ # Reconstruct the URL with the desired scheme
75
+ normalized_url = f"{scheme}://{host}:{port}"
76
+ logger.debug(f"normalized url [{url_string}] to [{normalized_url}]")
77
+ return normalized_url
78
+
79
+ except Exception as e:
80
+ if isinstance(e, SDKException):
81
+ raise e
82
+ raise SDKException(f"Error normalizing URL [{url_string}]: {e}") from e
otdf_python/aesgcm.py ADDED
@@ -0,0 +1,63 @@
1
+ """AES-GCM encryption and decryption functionality."""
2
+
3
+ import os
4
+
5
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
6
+
7
+
8
+ class AesGcm:
9
+ """AES-GCM encryption and decryption operations."""
10
+
11
+ GCM_NONCE_LENGTH = 12
12
+ GCM_TAG_LENGTH = 16
13
+
14
+ def __init__(self, key: bytes):
15
+ """Initialize AES-GCM cipher with key."""
16
+ if not key or len(key) not in (16, 24, 32):
17
+ raise ValueError("Invalid key size for GCM encryption")
18
+ self.key = key
19
+ self.aesgcm = AESGCM(key)
20
+
21
+ def get_key(self) -> bytes:
22
+ return self.key
23
+
24
+ class Encrypted:
25
+ """Encrypted data with initialization vector and ciphertext."""
26
+
27
+ def __init__(self, iv: bytes, ciphertext: bytes):
28
+ """Initialize encrypted data."""
29
+ self.iv = iv
30
+ self.ciphertext = ciphertext
31
+
32
+ def as_bytes(self) -> bytes:
33
+ return self.iv + self.ciphertext
34
+
35
+ def encrypt(
36
+ self, plaintext: bytes, offset: int = 0, length: int | None = None
37
+ ) -> "AesGcm.Encrypted":
38
+ if length is None:
39
+ length = len(plaintext) - offset
40
+ iv = os.urandom(self.GCM_NONCE_LENGTH)
41
+ ct = self.aesgcm.encrypt(iv, plaintext[offset : offset + length], None)
42
+ return self.Encrypted(iv, ct)
43
+
44
+ def encrypt_with_iv(
45
+ self,
46
+ iv: bytes,
47
+ auth_tag_len: int,
48
+ plaintext: bytes,
49
+ offset: int = 0,
50
+ length: int | None = None,
51
+ ) -> bytes:
52
+ if length is None:
53
+ length = len(plaintext) - offset
54
+ ct = self.aesgcm.encrypt(iv, plaintext[offset : offset + length], None)
55
+ return iv + ct
56
+
57
+ def decrypt(self, encrypted: "AesGcm.Encrypted") -> bytes:
58
+ return self.aesgcm.decrypt(encrypted.iv, encrypted.ciphertext, None)
59
+
60
+ def decrypt_with_iv(
61
+ self, iv: bytes, auth_tag_len: int, cipher_data: bytes
62
+ ) -> bytes:
63
+ return self.aesgcm.decrypt(iv, cipher_data, None)
@@ -0,0 +1,105 @@
1
+ """Assertion configuration for TDF."""
2
+
3
+ from enum import Enum, auto
4
+ from typing import Any
5
+
6
+
7
+ class Type(Enum):
8
+ """Assertion type enumeration."""
9
+
10
+ HANDLING_ASSERTION = "handling"
11
+ BASE_ASSERTION = "base"
12
+
13
+ def __str__(self):
14
+ return self.value
15
+
16
+
17
+ class Scope(Enum):
18
+ """Assertion scope enumeration."""
19
+
20
+ TRUSTED_DATA_OBJ = "tdo"
21
+ PAYLOAD = "payload"
22
+
23
+ def __str__(self):
24
+ return self.value
25
+
26
+
27
+ class AssertionKeyAlg(Enum):
28
+ """Assertion key algorithm enumeration."""
29
+
30
+ RS256 = auto()
31
+ HS256 = auto()
32
+ NOT_DEFINED = auto()
33
+
34
+
35
+ class AppliesToState(Enum):
36
+ """Assertion applies-to state enumeration."""
37
+
38
+ ENCRYPTED = "encrypted"
39
+ UNENCRYPTED = "unencrypted"
40
+
41
+ def __str__(self):
42
+ return self.value
43
+
44
+
45
+ class BindingMethod(Enum):
46
+ """Assertion binding method enumeration."""
47
+
48
+ JWS = "jws"
49
+
50
+ def __str__(self):
51
+ return self.value
52
+
53
+
54
+ class AssertionKey:
55
+ """Assertion signing key configuration."""
56
+
57
+ def __init__(self, alg: AssertionKeyAlg, key: Any):
58
+ """Initialize assertion key."""
59
+ self.alg = alg
60
+ self.key = key
61
+
62
+ def is_defined(self):
63
+ return self.alg != AssertionKeyAlg.NOT_DEFINED
64
+
65
+
66
+ class Statement:
67
+ """Assertion statement with format, schema, and value."""
68
+
69
+ def __init__(self, format: str, schema: str, value: str):
70
+ """Initialize assertion statement."""
71
+ self.format = format
72
+ self.schema = schema
73
+ self.value = value
74
+
75
+ def __eq__(self, other):
76
+ return (
77
+ isinstance(other, Statement)
78
+ and self.format == other.format
79
+ and self.schema == other.schema
80
+ and self.value == other.value
81
+ )
82
+
83
+ def __hash__(self):
84
+ return hash((self.format, self.schema, self.value))
85
+
86
+
87
+ class AssertionConfig:
88
+ """TDF assertion configuration."""
89
+
90
+ def __init__(
91
+ self,
92
+ id: str,
93
+ type: Type,
94
+ scope: Scope,
95
+ applies_to_state: AppliesToState,
96
+ statement: Statement,
97
+ signing_key: AssertionKey | None = None,
98
+ ):
99
+ """Initialize assertion configuration."""
100
+ self.id = id
101
+ self.type = type
102
+ self.scope = scope
103
+ self.applies_to_state = applies_to_state
104
+ self.statement = statement
105
+ self.signing_key = signing_key
@@ -0,0 +1,194 @@
1
+ """Asymmetric encryption and decryption utilities for RSA keys in PEM format."""
2
+
3
+ import base64
4
+ import re
5
+
6
+ from cryptography.hazmat.backends import default_backend
7
+ from cryptography.hazmat.primitives import hashes, serialization
8
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
9
+ from cryptography.x509 import load_pem_x509_certificate
10
+
11
+ from .sdk_exceptions import SDKException
12
+
13
+
14
+ class AsymDecryption:
15
+ """Provides functionality for asymmetric decryption using an RSA private key.
16
+
17
+ Supports both PEM string and key object initialization for flexibility.
18
+ """
19
+
20
+ CIPHER_TRANSFORM = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"
21
+ PRIVATE_KEY_HEADER = "-----BEGIN PRIVATE KEY-----"
22
+ PRIVATE_KEY_FOOTER = "-----END PRIVATE KEY-----"
23
+
24
+ def __init__(self, private_key_pem: str | None = None, private_key_obj=None):
25
+ """Initialize with either a PEM string or a key object.
26
+
27
+ Args:
28
+ private_key_pem: Private key in PEM format (with or without headers)
29
+ private_key_obj: Pre-loaded private key object from cryptography library
30
+
31
+ Raises:
32
+ SDKException: If key loading fails
33
+
34
+ """
35
+ if private_key_obj is not None:
36
+ self.private_key = private_key_obj
37
+ elif private_key_pem is not None:
38
+ try:
39
+ # Try direct PEM loading first (most common case)
40
+ try:
41
+ self.private_key = serialization.load_pem_private_key(
42
+ private_key_pem.encode(),
43
+ password=None,
44
+ backend=default_backend(),
45
+ )
46
+ except Exception:
47
+ # Fallback: strip headers and load as DER (for base64-only keys)
48
+ private_key_pem = (
49
+ private_key_pem.replace(self.PRIVATE_KEY_HEADER, "")
50
+ .replace(self.PRIVATE_KEY_FOOTER, "")
51
+ .replace("\n", "")
52
+ .replace("\r", "")
53
+ .replace(" ", "")
54
+ )
55
+ decoded = base64.b64decode(private_key_pem)
56
+ self.private_key = serialization.load_der_private_key(
57
+ decoded, password=None, backend=default_backend()
58
+ )
59
+ except Exception as e:
60
+ raise SDKException(f"Failed to load private key: {e}") from e
61
+ else:
62
+ self.private_key = None
63
+
64
+ def decrypt(self, data: bytes) -> bytes:
65
+ """Decrypt data using RSA OAEP with SHA-1.
66
+
67
+ Args:
68
+ data: Encrypted bytes to decrypt
69
+
70
+ Returns:
71
+ Decrypted bytes
72
+
73
+ Raises:
74
+ SDKException: If decryption fails or key is not set
75
+
76
+ """
77
+ if self.private_key is None:
78
+ raise SDKException("Failed to decrypt, private key is empty")
79
+ try:
80
+ return self.private_key.decrypt(
81
+ data,
82
+ padding.OAEP(
83
+ mgf=padding.MGF1(algorithm=hashes.SHA1()),
84
+ algorithm=hashes.SHA1(),
85
+ label=None,
86
+ ),
87
+ )
88
+ except Exception as e:
89
+ raise SDKException(f"Error performing decryption: {e}") from e
90
+
91
+
92
+ class AsymEncryption:
93
+ """Provides functionality for asymmetric encryption using an RSA public key or certificate in PEM format.
94
+
95
+ Supports PEM public keys, X.509 certificates, and pre-loaded key objects.
96
+ Also handles base64-encoded keys without PEM headers.
97
+ """
98
+
99
+ PUBLIC_KEY_HEADER = "-----BEGIN PUBLIC KEY-----"
100
+ PUBLIC_KEY_FOOTER = "-----END PUBLIC KEY-----"
101
+ CIPHER_TRANSFORM = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"
102
+
103
+ def __init__(self, public_key_pem: str | None = None, public_key_obj=None):
104
+ """Initialize with either a PEM string or a key object.
105
+
106
+ Args:
107
+ public_key_pem: Public key in PEM format, X.509 certificate, or base64 string
108
+ public_key_obj: Pre-loaded public key object from cryptography library
109
+
110
+ Raises:
111
+ SDKException: If key loading fails or key is not RSA
112
+
113
+ """
114
+ if public_key_obj is not None:
115
+ self.public_key = public_key_obj
116
+ elif public_key_pem is not None:
117
+ try:
118
+ if "BEGIN CERTIFICATE" in public_key_pem:
119
+ # Load from X.509 certificate
120
+ cert = load_pem_x509_certificate(
121
+ public_key_pem.encode(), default_backend()
122
+ )
123
+ self.public_key = cert.public_key()
124
+ else:
125
+ # Try direct PEM loading first (most common case)
126
+ try:
127
+ self.public_key = serialization.load_pem_public_key(
128
+ public_key_pem.encode(), backend=default_backend()
129
+ )
130
+ except Exception:
131
+ # Fallback: strip headers and load as DER (for base64-only keys)
132
+ pem_body = re.sub(r"-----BEGIN (.*)-----", "", public_key_pem)
133
+ pem_body = re.sub(r"-----END (.*)-----", "", pem_body)
134
+ pem_body = re.sub(r"\s", "", pem_body)
135
+ decoded = base64.b64decode(pem_body)
136
+ self.public_key = serialization.load_der_public_key(
137
+ decoded, backend=default_backend()
138
+ )
139
+ except Exception as e:
140
+ raise SDKException(f"Failed to load public key: {e}") from e
141
+ else:
142
+ self.public_key = None
143
+
144
+ # Validate that it's an RSA key
145
+ if self.public_key is not None and not isinstance(
146
+ self.public_key, rsa.RSAPublicKey
147
+ ):
148
+ raise SDKException("Not an RSA PEM formatted public key")
149
+
150
+ def encrypt(self, data: bytes) -> bytes:
151
+ """Encrypt data using RSA OAEP with SHA-1.
152
+
153
+ Args:
154
+ data: Plaintext bytes to encrypt
155
+
156
+ Returns:
157
+ Encrypted bytes
158
+
159
+ Raises:
160
+ SDKException: If encryption fails or key is not set
161
+
162
+ """
163
+ if self.public_key is None:
164
+ raise SDKException("Failed to encrypt, public key is empty")
165
+ try:
166
+ return self.public_key.encrypt(
167
+ data,
168
+ padding.OAEP(
169
+ mgf=padding.MGF1(algorithm=hashes.SHA1()),
170
+ algorithm=hashes.SHA1(),
171
+ label=None,
172
+ ),
173
+ )
174
+ except Exception as e:
175
+ raise SDKException(f"Error performing encryption: {e}") from e
176
+
177
+ def public_key_in_pem_format(self) -> str:
178
+ """Export the public key to PEM format.
179
+
180
+ Returns:
181
+ Public key as PEM-encoded string
182
+
183
+ Raises:
184
+ SDKException: If export fails
185
+
186
+ """
187
+ try:
188
+ pem = self.public_key.public_bytes(
189
+ encoding=serialization.Encoding.PEM,
190
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
191
+ )
192
+ return pem.decode()
193
+ except Exception as e:
194
+ raise SDKException(f"Error exporting public key to PEM: {e}") from e
@@ -0,0 +1,35 @@
1
+ """Authentication header management."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class AuthHeaders:
8
+ """Represents authentication headers used in token-based authorization.
9
+
10
+ This class holds authorization and DPoP (Demonstrating Proof of Possession) headers
11
+ that are used in token-based API requests.
12
+ """
13
+
14
+ auth_header: str
15
+ dpop_header: str = ""
16
+
17
+ def get_auth_header(self) -> str:
18
+ """Get the authorization header."""
19
+ return self.auth_header
20
+
21
+ def get_dpop_header(self) -> str:
22
+ """Get the DPoP header."""
23
+ return self.dpop_header
24
+
25
+ def to_dict(self) -> dict[str, str]:
26
+ """Convert authentication headers to a dictionary for use with HTTP clients.
27
+
28
+ Returns:
29
+ Dictionary with 'Authorization' header and optionally 'DPoP' header
30
+
31
+ """
32
+ headers = {"Authorization": self.auth_header}
33
+ if self.dpop_header:
34
+ headers["DPoP"] = self.dpop_header
35
+ return headers
@@ -0,0 +1,127 @@
1
+ """Utilities for automatic SDK configuration."""
2
+
3
+ import re
4
+ import urllib.parse
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ # RuleType constants
10
+ class RuleType:
11
+ """Rule type constants for attribute hierarchy."""
12
+
13
+ HIERARCHY = "hierarchy"
14
+ ALL_OF = "allOf"
15
+ ANY_OF = "anyOf"
16
+ UNSPECIFIED = "unspecified"
17
+ EMPTY_TERM = "DEFAULT"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class KeySplitStep:
22
+ """Key split step information."""
23
+
24
+ kas: str
25
+ splitID: str
26
+
27
+ def __str__(self):
28
+ return f"KeySplitStep{{kas={self.kas}, splitID={self.splitID}}}"
29
+
30
+ def __eq__(self, other: Any) -> bool:
31
+ if not isinstance(other, KeySplitStep):
32
+ return False
33
+ return self.kas == other.kas and self.splitID == other.splitID
34
+
35
+ def __hash__(self):
36
+ return hash((self.kas, self.splitID))
37
+
38
+
39
+ class AutoConfigureException(Exception):
40
+ """Exception for auto-configuration errors."""
41
+
42
+
43
+ class AttributeNameFQN:
44
+ """Fully qualified attribute name."""
45
+
46
+ def __init__(self, url: str):
47
+ """Initialize attribute name from URL."""
48
+ pattern = re.compile(r"^(https?://[\w./-]+)/attr/([^/\s]*)$")
49
+ matcher = pattern.match(url)
50
+ if not matcher or not matcher.group(1) or not matcher.group(2):
51
+ raise AutoConfigureException("invalid type: attribute regex fail")
52
+ try:
53
+ urllib.parse.unquote(matcher.group(2))
54
+ except Exception as err:
55
+ raise AutoConfigureException(
56
+ f"invalid type: error in attribute name [{matcher.group(2)}]"
57
+ ) from err
58
+ self.url = url
59
+ self.key = url.lower()
60
+
61
+ def __str__(self):
62
+ return self.url
63
+
64
+ def select(self, value: str):
65
+ new_url = f"{self.url}/value/{urllib.parse.quote(value)}"
66
+ return AttributeValueFQN(new_url)
67
+
68
+ def prefix(self):
69
+ return self.url
70
+
71
+ def get_key(self):
72
+ return self.key
73
+
74
+ def authority(self):
75
+ pattern = re.compile(r"^(https?://[\w./-]+)/attr/[^/\s]*$")
76
+ matcher = pattern.match(self.url)
77
+ if not matcher:
78
+ raise AutoConfigureException("invalid type")
79
+ return matcher.group(1)
80
+
81
+ def name(self):
82
+ pattern = re.compile(r"^https?://[\w./-]+/attr/([^/\s]*)$")
83
+ matcher = pattern.match(self.url)
84
+ if not matcher:
85
+ raise AutoConfigureException("invalid attribute")
86
+ try:
87
+ return urllib.parse.unquote(matcher.group(1))
88
+ except Exception as err:
89
+ raise AutoConfigureException("invalid type") from err
90
+
91
+
92
+ class AttributeValueFQN:
93
+ """Fully qualified attribute value."""
94
+
95
+ def __init__(self, url: str):
96
+ """Initialize attribute value from URL."""
97
+ pattern = re.compile(r"^(https?://[\w./-]+)/attr/(\S*)/value/(\S*)$")
98
+ matcher = pattern.match(url)
99
+ if (
100
+ not matcher
101
+ or not matcher.group(1)
102
+ or not matcher.group(2)
103
+ or not matcher.group(3)
104
+ ):
105
+ raise AutoConfigureException(
106
+ f"invalid type: attribute regex fail for [{url}]"
107
+ )
108
+ try:
109
+ urllib.parse.unquote(matcher.group(2))
110
+ urllib.parse.unquote(matcher.group(3))
111
+ except Exception as err:
112
+ raise AutoConfigureException(
113
+ "invalid type: error in attribute or value"
114
+ ) from err
115
+ self.url = url
116
+ self.key = url.lower()
117
+
118
+ def __str__(self):
119
+ return self.url
120
+
121
+ def __eq__(self, other: Any) -> bool:
122
+ if not isinstance(other, AttributeValueFQN):
123
+ return False
124
+ return self.key == other.key
125
+
126
+ def __hash__(self):
127
+ return hash(self.key)