mercuto-client 0.2.6.dev0__tar.gz → 0.3.2a0__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.

Potentially problematic release.


This version of mercuto-client might be problematic. Click here for more details.

Files changed (58) hide show
  1. mercuto_client-0.3.2a0/PKG-INFO +72 -0
  2. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/README.md +12 -6
  3. mercuto_client-0.3.2a0/mercuto_client/__init__.py +8 -0
  4. mercuto_client-0.3.2a0/mercuto_client/_authentication.py +72 -0
  5. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/_tests/test_ingester/test_file_processor.py +1 -1
  6. mercuto_client-0.3.2a0/mercuto_client/_tests/test_ingester/test_parsers.py +145 -0
  7. mercuto_client-0.3.2a0/mercuto_client/_tests/test_mocking/conftest.py +13 -0
  8. mercuto_client-0.3.2a0/mercuto_client/_tests/test_mocking/test_mock_identity.py +8 -0
  9. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/acl.py +16 -10
  10. mercuto_client-0.3.2a0/mercuto_client/client.py +202 -0
  11. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/exceptions.py +5 -1
  12. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/ingester/__main__.py +8 -129
  13. mercuto_client-0.3.2a0/mercuto_client/ingester/mercuto.py +154 -0
  14. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/ingester/parsers/__init__.py +3 -3
  15. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/ingester/parsers/campbell.py +2 -2
  16. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/ingester/parsers/generic_csv.py +5 -5
  17. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/ingester/parsers/worldsensing.py +4 -3
  18. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/ingester/processor.py +14 -12
  19. mercuto_client-0.3.2a0/mercuto_client/mocks/__init__.py +140 -0
  20. mercuto_client-0.3.2a0/mercuto_client/mocks/_utility.py +75 -0
  21. mercuto_client-0.3.2a0/mercuto_client/mocks/mock_core.py +29 -0
  22. mercuto_client-0.3.2a0/mercuto_client/mocks/mock_data.py +402 -0
  23. mercuto_client-0.3.2a0/mercuto_client/mocks/mock_fatigue.py +30 -0
  24. mercuto_client-0.3.2a0/mercuto_client/mocks/mock_identity.py +188 -0
  25. mercuto_client-0.3.2a0/mercuto_client/mocks/mock_media.py +117 -0
  26. mercuto_client-0.3.2a0/mercuto_client/modules/__init__.py +23 -0
  27. mercuto_client-0.3.2a0/mercuto_client/modules/_util.py +18 -0
  28. mercuto_client-0.3.2a0/mercuto_client/modules/core.py +545 -0
  29. mercuto_client-0.3.2a0/mercuto_client/modules/data.py +623 -0
  30. mercuto_client-0.3.2a0/mercuto_client/modules/fatigue.py +189 -0
  31. mercuto_client-0.3.2a0/mercuto_client/modules/identity.py +254 -0
  32. mercuto_client-0.3.2a0/mercuto_client/modules/media.py +315 -0
  33. mercuto_client-0.3.2a0/mercuto_client/py.typed +0 -0
  34. {mercuto_client-0.2.6.dev0/mercuto_client/ingester → mercuto_client-0.3.2a0/mercuto_client}/util.py +27 -11
  35. mercuto_client-0.3.2a0/mercuto_client.egg-info/PKG-INFO +72 -0
  36. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client.egg-info/SOURCES.txt +21 -6
  37. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client.egg-info/requires.txt +1 -0
  38. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/pyproject.toml +11 -5
  39. mercuto_client-0.2.6.dev0/PKG-INFO +0 -20
  40. mercuto_client-0.2.6.dev0/mercuto_client/__init__.py +0 -30
  41. mercuto_client-0.2.6.dev0/mercuto_client/_tests/test_ingester/test_parsers.py +0 -145
  42. mercuto_client-0.2.6.dev0/mercuto_client/_tests/test_mocking.py +0 -93
  43. mercuto_client-0.2.6.dev0/mercuto_client/_util.py +0 -13
  44. mercuto_client-0.2.6.dev0/mercuto_client/client.py +0 -903
  45. mercuto_client-0.2.6.dev0/mercuto_client/mocks.py +0 -203
  46. mercuto_client-0.2.6.dev0/mercuto_client/types.py +0 -409
  47. mercuto_client-0.2.6.dev0/mercuto_client.egg-info/PKG-INFO +0 -20
  48. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/LICENSE +0 -0
  49. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/_tests/__init__.py +0 -0
  50. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/_tests/conftest.py +0 -0
  51. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/_tests/test_ingester/__init__.py +0 -0
  52. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/_tests/test_ingester/test_ftp.py +0 -0
  53. {mercuto_client-0.2.6.dev0/mercuto_client/ingester → mercuto_client-0.3.2a0/mercuto_client/_tests/test_mocking}/__init__.py +0 -0
  54. /mercuto_client-0.2.6.dev0/mercuto_client/py.typed → /mercuto_client-0.3.2a0/mercuto_client/ingester/__init__.py +0 -0
  55. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client/ingester/ftp.py +0 -0
  56. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client.egg-info/dependency_links.txt +0 -0
  57. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/mercuto_client.egg-info/top_level.txt +0 -0
  58. {mercuto_client-0.2.6.dev0 → mercuto_client-0.3.2a0}/setup.cfg +0 -0
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: mercuto-client
3
+ Version: 0.3.2a0
4
+ Summary: Library for interfacing with Rockfield's Mercuto API
5
+ Author-email: Daniel Whipp <daniel.whipp@rocktech.com.au>
6
+ License-Expression: AGPL-3.0-only
7
+ Project-URL: Homepage, https://mercuto.rockfieldcloud.com.au
8
+ Project-URL: Repository, https://github.com/RockfieldTechnologiesAustralia/mercuto-client
9
+ Project-URL: Documentation, https://github.com/RockfieldTechnologiesAustralia/mercuto-client/blob/main/README.md
10
+ Keywords: mercuto,rockfield,infratech
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Build Tools
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: requests>=2.32
21
+ Requires-Dist: pyftpdlib>=2.0.1
22
+ Requires-Dist: python-dateutil>=2.9.0.post0
23
+ Requires-Dist: pytz>=2025.2
24
+ Requires-Dist: schedule>=1.2.2
25
+ Requires-Dist: pydantic>=2.0
26
+ Dynamic: license-file
27
+
28
+ # Mercuto Client Python Library
29
+
30
+ Library for interfacing with Rockfield's Mercuto public API.
31
+ This library is in an early development state and is subject to major structural changes at any time.
32
+
33
+ (Visit our Github Repository)[https://github.com/RockfieldTechnologiesAustralia/mercuto-client]
34
+
35
+ ## Installation
36
+ Install from PyPi: `pip install mercuto-client` or adding the same line into your `requirements.txt`.
37
+
38
+ ## Basic Usage
39
+
40
+ Use the `connect()` function exposed within the main package and provide your API key.
41
+
42
+ ```python
43
+ from mercuto_client import connect
44
+
45
+ client = connect(api_key="<YOUR API KEY>")
46
+ print(client.core().list_projects())
47
+
48
+ # Logout after finished.
49
+ client.logout()
50
+
51
+ ```
52
+
53
+ You can also use the client as a context manager. It will logout automatically.
54
+
55
+ ```python
56
+ from mercuto_client import MercutoClient
57
+
58
+ with MercutoClient.as_credentials(api_key='<YOUR API KEY>') as client:
59
+ print(client.core().list_projects())
60
+ ```
61
+
62
+ ## Current Status
63
+ This library is incomplete and may not be fully compliant with the latest Mercuto version. It is only updated periodically and provided for use without any warranty or guarantees.
64
+
65
+ - [x] API Based login (Completed)
66
+ - [ ] Username/password login
67
+
68
+ ## Running tests
69
+ Install test packages:
70
+ `python -m uv sync --group tests`
71
+ Run tests:
72
+ `uv run pytest`
@@ -3,10 +3,10 @@
3
3
  Library for interfacing with Rockfield's Mercuto public API.
4
4
  This library is in an early development state and is subject to major structural changes at any time.
5
5
 
6
+ (Visit our Github Repository)[https://github.com/RockfieldTechnologiesAustralia/mercuto-client]
7
+
6
8
  ## Installation
7
- Currently this library is not available on any package repository. It can be installed directly from Github using:
8
- `pip install git+https://github.com/RockfieldTechnologiesAustralia/mercuto-client@0.1.0` or adding the same line into
9
- your `requirements.txt`.
9
+ Install from PyPi: `pip install mercuto-client` or adding the same line into your `requirements.txt`.
10
10
 
11
11
  ## Basic Usage
12
12
 
@@ -16,7 +16,7 @@ Use the `connect()` function exposed within the main package and provide your AP
16
16
  from mercuto_client import connect
17
17
 
18
18
  client = connect(api_key="<YOUR API KEY>")
19
- print(client.projects().get_projects())
19
+ print(client.core().list_projects())
20
20
 
21
21
  # Logout after finished.
22
22
  client.logout()
@@ -29,11 +29,17 @@ You can also use the client as a context manager. It will logout automatically.
29
29
  from mercuto_client import MercutoClient
30
30
 
31
31
  with MercutoClient.as_credentials(api_key='<YOUR API KEY>') as client:
32
- print(client.projects().get_projects())
32
+ print(client.core().list_projects())
33
33
  ```
34
34
 
35
35
  ## Current Status
36
36
  This library is incomplete and may not be fully compliant with the latest Mercuto version. It is only updated periodically and provided for use without any warranty or guarantees.
37
37
 
38
38
  - [x] API Based login (Completed)
39
- - [ ] Username/password login
39
+ - [ ] Username/password login
40
+
41
+ ## Running tests
42
+ Install test packages:
43
+ `python -m uv sync --group tests`
44
+ Run tests:
45
+ `uv run pytest`
@@ -0,0 +1,8 @@
1
+ from .client import MercutoClient
2
+ from .exceptions import MercutoClientException, MercutoHTTPException
3
+
4
+ __all__ = ['MercutoClient', 'MercutoHTTPException', 'MercutoClientException']
5
+
6
+
7
+ def connect(*args, **kwargs) -> MercutoClient:
8
+ return MercutoClient().connect(*args, **kwargs)
@@ -0,0 +1,72 @@
1
+ from typing import Mapping, Optional
2
+
3
+
4
+ class IAuthenticationMethod:
5
+ def update_header(self, header: dict[str, str]) -> None:
6
+ return
7
+
8
+ def unique_key(self) -> str:
9
+ raise NotImplementedError(f"unique_key not implemented for type {self.__class__.__name__}")
10
+
11
+
12
+ class ApiKeyAuthentication(IAuthenticationMethod):
13
+ def __init__(self, api_key: str) -> None:
14
+ self.api_key = api_key
15
+
16
+ def update_header(self, header: dict[str, str]) -> None:
17
+ header['X-Api-Key'] = self.api_key
18
+
19
+ def unique_key(self) -> str:
20
+ return f'api-key:{self.api_key}'
21
+
22
+
23
+ class ServiceTokenAuthentication(IAuthenticationMethod):
24
+ def __init__(self, service_token: str) -> None:
25
+ self.service_token = service_token
26
+
27
+ def update_header(self, header: dict[str, str]) -> None:
28
+ header['X-Service-Token'] = self.service_token
29
+
30
+ def unique_key(self) -> str:
31
+ return f'service-token:{self.service_token}'
32
+
33
+
34
+ class AuthorizationHeaderAuthentication(IAuthenticationMethod):
35
+ def __init__(self, authorization_header: str) -> None:
36
+ if not authorization_header.startswith('Bearer '):
37
+ authorization_header = 'Bearer ' + authorization_header
38
+ self.authorization_header = authorization_header
39
+
40
+ def update_header(self, header: dict[str, str]) -> None:
41
+ header['Authorization'] = self.authorization_header
42
+
43
+ def unique_key(self) -> str:
44
+ return f'auth-bearer:{self.authorization_header}'
45
+
46
+
47
+ class NullAuthenticationMethod(IAuthenticationMethod):
48
+ def update_header(self, header: dict[str, str]) -> None:
49
+ pass
50
+
51
+ def unique_key(self) -> str:
52
+ return 'null-authentication'
53
+
54
+
55
+ def create_authentication_method(api_key: Optional[str] = None,
56
+ service_token: Optional[str] = None,
57
+ bearer_token: Optional[str] = None,
58
+ headers: Optional[Mapping[str, str]] = None) -> IAuthenticationMethod:
59
+ if api_key is not None and service_token is not None and headers is not None and bearer_token is not None:
60
+ raise ValueError("Only one of api_key or service_token or bearer_token can be provided")
61
+ if headers is not None:
62
+ api_key = headers.get('X-Api-Key', None)
63
+ service_token = headers.get('X-Service-Token', None)
64
+ bearer_token = headers.get('Authorization', None)
65
+ if api_key is not None:
66
+ return ApiKeyAuthentication(api_key)
67
+ elif service_token is not None:
68
+ return ServiceTokenAuthentication(service_token)
69
+ elif bearer_token is not None:
70
+ return AuthorizationHeaderAuthentication(bearer_token)
71
+ else:
72
+ return NullAuthenticationMethod()
@@ -37,7 +37,7 @@ def temp_env() -> Generator[Tuple[FileProcessor, str, str], None, None]:
37
37
 
38
38
  def test_init_db(temp_env: Tuple[FileProcessor, str, str]) -> None:
39
39
  """Verify database initialization"""
40
- processor, _, db_path = temp_env
40
+ _, _, db_path = temp_env
41
41
  conn: sqlite3.Connection = sqlite3.connect(db_path)
42
42
  cursor: sqlite3.Cursor = conn.cursor()
43
43
  cursor.execute(
@@ -0,0 +1,145 @@
1
+ import math
2
+ import os
3
+ import tempfile
4
+
5
+ import pytest
6
+
7
+ from ...ingester.parsers import (detect_parser, parse_campbell_file,
8
+ parse_worldsensing_compact_file,
9
+ parse_worldsensing_standard_file)
10
+
11
+ RESOURCES_DIR = os.path.join(os.path.dirname(__file__), "resources")
12
+
13
+
14
+ def test_worldsensing_compacted_parser():
15
+ file = os.path.join(RESOURCES_DIR, "worldsensing-compacted-sample-file.dat")
16
+ mapper = {
17
+ "channel1": "12345678",
18
+ "channel2": "abcdefgh",
19
+ }
20
+ samples = parse_worldsensing_compact_file(file, mapper)
21
+ assert len(samples) == 4
22
+ assert samples[0].channel == "12345678"
23
+ assert math.isclose(samples[0].value, -10)
24
+ assert samples[0].timestamp.isoformat() == '2025-05-20T15:00:00'
25
+
26
+ assert samples[1].channel == "abcdefgh"
27
+ assert math.isclose(samples[1].value, 5)
28
+ assert samples[0].timestamp.isoformat() == '2025-05-20T15:00:00'
29
+
30
+ assert samples[2].channel == "12345678"
31
+ assert math.isclose(samples[2].value, -12)
32
+ assert samples[2].timestamp.isoformat() == '2025-05-20T16:00:00'
33
+
34
+ assert samples[3].channel == "abcdefgh"
35
+ assert math.isclose(samples[3].value, 10)
36
+ assert samples[3].timestamp.isoformat() == '2025-05-20T16:00:00'
37
+
38
+
39
+ def test_worldsensing_standard_parser():
40
+ file = os.path.join(RESOURCES_DIR, "worldsensing-standard-sample-file.csv")
41
+ mapper = {
42
+ "AtmPressure-85544-in-mbar": "12345678",
43
+ "freqSqInDigit-85544-VW-Ch1": "abcdefgh",
44
+ }
45
+ samples = parse_worldsensing_standard_file(file, mapper)
46
+ assert len(samples) == 10
47
+ assert samples[0].channel == "12345678"
48
+ assert math.isclose(samples[0].value, 930.5)
49
+ assert samples[0].timestamp.isoformat() == '2024-04-15T12:35:00'
50
+
51
+ assert samples[1].channel == "abcdefgh"
52
+ assert math.isclose(samples[1].value, 726.810811024)
53
+ assert samples[1].timestamp.isoformat() == '2024-04-15T12:35:00'
54
+
55
+ assert samples[8].channel == "12345678"
56
+ assert math.isclose(samples[8].value, 930.4)
57
+ assert samples[8].timestamp.isoformat() == '2024-04-15T12:39:00'
58
+
59
+ assert samples[9].channel == "abcdefgh"
60
+ assert math.isclose(samples[9].value, 726.841502500)
61
+ assert samples[9].timestamp.isoformat() == '2024-04-15T12:39:00'
62
+
63
+
64
+ def test_campbells_parser():
65
+ file = os.path.join(RESOURCES_DIR, "campbell-sample-file.dat")
66
+ mapper = {
67
+ "VWu_1": "aaaaaaaa",
68
+ "VWu_2": "bbbbbbbb",
69
+ "Therm(1)": "cccccccc",
70
+ "Therm(2)": "dddddddd",
71
+ "Diag_Max(1)": "eeeeeeee",
72
+ "Diag_Max(2)": "ffffffff",
73
+ }
74
+ samples = parse_campbell_file(file, mapper)
75
+ assert len(samples) == 6*4
76
+
77
+ assert samples[0].channel == "aaaaaaaa"
78
+ assert math.isclose(samples[0].value, 1234.5)
79
+ assert samples[0].timestamp.isoformat() == '2023-12-07T00:01:00'
80
+
81
+ for i in range(1, 6):
82
+ assert samples[i].channel == list(mapper.values())[i]
83
+ assert math.isnan(samples[i].value)
84
+ assert samples[i].timestamp.isoformat() == '2023-12-07T00:01:00'
85
+
86
+ assert samples[6].channel == "aaaaaaaa"
87
+ assert math.isclose(samples[6].value, 1234.5)
88
+ assert samples[6].timestamp.isoformat() == '2023-12-07T00:02:00'
89
+ for i in range(7, 12):
90
+ assert samples[i].channel == list(mapper.values())[i-6]
91
+ assert math.isnan(samples[i].value)
92
+ assert samples[i].timestamp.isoformat() == '2023-12-07T00:02:00'
93
+
94
+ assert samples[12].channel == "aaaaaaaa"
95
+ assert math.isclose(samples[12].value, 1234.5)
96
+ assert samples[12].timestamp.isoformat() == '2023-12-07T00:03:00'
97
+ assert samples[13].channel == "bbbbbbbb"
98
+ assert math.isclose(samples[13].value, 1234.5)
99
+ assert samples[13].timestamp.isoformat() == '2023-12-07T00:03:00'
100
+
101
+ for i in range(15, 17):
102
+ assert samples[i].channel == list(mapper.values())[i-12]
103
+ assert math.isnan(samples[i].value)
104
+ assert samples[i].timestamp.isoformat() == '2023-12-07T00:03:00'
105
+ assert samples[17].channel == "ffffffff"
106
+ assert math.isclose(samples[17].value, 1537)
107
+ assert samples[17].timestamp.isoformat() == '2023-12-07T00:03:00'
108
+
109
+ assert samples[18].channel == "aaaaaaaa"
110
+ assert math.isclose(samples[18].value, 1234.5)
111
+ assert samples[18].timestamp.isoformat() == '2023-12-07T00:04:00'
112
+ assert samples[19].channel == "bbbbbbbb"
113
+ assert math.isclose(samples[19].value, 1234.5)
114
+ assert samples[19].timestamp.isoformat() == '2023-12-07T00:04:00'
115
+ assert samples[20].channel == "cccccccc"
116
+ assert math.isclose(samples[20].value, 27.5)
117
+ assert samples[20].timestamp.isoformat() == '2023-12-07T00:04:00'
118
+ assert samples[21].channel == "dddddddd"
119
+ assert math.isclose(samples[21].value, 25)
120
+ assert samples[21].timestamp.isoformat() == '2023-12-07T00:04:00'
121
+ assert samples[22].channel == "eeeeeeee"
122
+ assert math.isclose(samples[22].value, 255)
123
+ assert samples[22].timestamp.isoformat() == '2023-12-07T00:04:00'
124
+ assert samples[23].channel == "ffffffff"
125
+ assert math.isclose(samples[23].value, 0)
126
+ assert samples[23].timestamp.isoformat() == '2023-12-07T00:04:00'
127
+
128
+
129
+ def test_detect_file_type():
130
+ compacted_file = os.path.join(RESOURCES_DIR, "worldsensing-compacted-sample-file.dat")
131
+ standard_file = os.path.join(RESOURCES_DIR, "worldsensing-standard-sample-file.csv")
132
+ campbell_file = os.path.join(RESOURCES_DIR, "campbell-sample-file.dat")
133
+
134
+ assert detect_parser(compacted_file) == parse_worldsensing_compact_file
135
+ assert detect_parser(standard_file) == parse_worldsensing_standard_file
136
+ assert detect_parser(campbell_file) == parse_campbell_file
137
+
138
+ # Test with an unknown file type
139
+ with tempfile.TemporaryDirectory() as dir:
140
+ unknown_file = os.path.join(dir, "unknown-file.txt")
141
+ with open(unknown_file, "w") as f:
142
+ f.write("This is an unknown file format.")
143
+
144
+ with pytest.raises(ValueError):
145
+ detect_parser(unknown_file)
@@ -0,0 +1,13 @@
1
+ from typing import Iterator
2
+
3
+ import pytest
4
+
5
+ from ... import MercutoClient
6
+ from ...mocks import mock_mercuto
7
+
8
+
9
+ @pytest.fixture
10
+ def client() -> Iterator[MercutoClient]:
11
+ with mock_mercuto():
12
+ client = MercutoClient("https://testserver")
13
+ yield client
@@ -0,0 +1,8 @@
1
+ from ... import MercutoClient
2
+
3
+
4
+ def test_add_and_list_users(client: MercutoClient) -> None:
5
+ client.identity().create_user("test_user", "test-tenant", "Description", "Test Group")
6
+ users = client.identity().list_users()
7
+ assert len(users) == 1
8
+ assert "test_user" in [user.username for user in users]
@@ -1,7 +1,5 @@
1
1
  import json
2
- from typing import TypeVar
3
-
4
- from .types import AccessControlListJson, AccessControlListJsonEntry
2
+ from typing import TypedDict, TypeVar
5
3
 
6
4
 
7
5
  class ResourceTypes:
@@ -65,36 +63,44 @@ class ServiceTypes:
65
63
  WILDCARD = '*'
66
64
 
67
65
 
68
- T = TypeVar('T', bound='AclPolicyBuilder')
66
+ _T = TypeVar('_T', bound='AclPolicyBuilder')
69
67
 
70
68
 
71
69
  class AclPolicyBuilder:
70
+ class _AclEntryType(TypedDict):
71
+ action: str
72
+ resource: str
73
+
74
+ class _AclPolicyType(TypedDict):
75
+ version: int
76
+ permissions: list['AclPolicyBuilder._AclEntryType']
77
+
72
78
  def __init__(self) -> None:
73
- self._permissions: list[AccessControlListJsonEntry] = []
79
+ self._permissions: list[AclPolicyBuilder._AclEntryType] = []
74
80
 
75
- def allow(self: T, action: str, resource: str) -> T:
81
+ def allow(self: _T, action: str, resource: str) -> _T:
76
82
  self._permissions.append({
77
83
  'action': action,
78
84
  'resource': resource
79
85
  })
80
86
  return self
81
87
 
82
- def allow_all(self: T, action: str) -> T:
88
+ def allow_all(self: _T, action: str) -> _T:
83
89
  self.allow(action, f"mrn:{ServiceTypes.WILDCARD}:{ResourceTypes.WILDCARD}/{ResourceTypes.WILDCARD}")
84
90
  return self
85
91
 
86
- def allow_project(self: T, action: str, project_code: str) -> T:
92
+ def allow_project(self: _T, action: str, project_code: str) -> _T:
87
93
  self.allow(action, f"mrn:{ServiceTypes.MERCUTO}:{ResourceTypes.Mercuto.PROJECT}/{project_code}")
88
94
  return self
89
95
 
90
- def allow_tenant(self: T, action: str, tenant_code: str) -> T:
96
+ def allow_tenant(self: _T, action: str, tenant_code: str) -> _T:
91
97
  self.allow(action, f"mrn:{ServiceTypes.IDENTITY}:{ResourceTypes.Identity.TENANT}/{tenant_code}")
92
98
  return self
93
99
 
94
100
  def as_string(self) -> str:
95
101
  return json.dumps(self.as_dict())
96
102
 
97
- def as_dict(self) -> AccessControlListJson:
103
+ def as_dict(self) -> _AclPolicyType:
98
104
  return {
99
105
  'version': 1,
100
106
  'permissions': self._permissions
@@ -0,0 +1,202 @@
1
+ import contextlib
2
+ import json as json_stdlib
3
+ import logging
4
+ import os
5
+ import time
6
+ from typing import (Any, Iterator, Literal, Mapping, Optional, Protocol, Type,
7
+ TypeVar)
8
+
9
+ import requests
10
+ import requests.cookies
11
+
12
+ from ._authentication import (IAuthenticationMethod,
13
+ create_authentication_method)
14
+ from .exceptions import MercutoClientException, MercutoHTTPException
15
+ from .modules.core import MercutoCoreService
16
+ from .modules.data import MercutoDataService
17
+ from .modules.fatigue import MercutoFatigueService
18
+ from .modules.identity import MercutoIdentityService
19
+ from .modules.media import MercutoMediaService
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class _ModuleBase(Protocol):
25
+ def __init__(self, client: 'MercutoClient', *args: Any, **kwargs: Any) -> None:
26
+ pass
27
+
28
+
29
+ _T = TypeVar('_T', bound=_ModuleBase)
30
+
31
+
32
+ class MercutoClient:
33
+ def __init__(self, url: Optional[str] = None, verify_ssl: bool = True, active_session: Optional[requests.Session] = None) -> None:
34
+ if url is None:
35
+ url = os.environ.get('MERCUTO_API_URL', 'https://api.rockfieldcloud.com.au')
36
+ assert isinstance(url, str)
37
+
38
+ if url.endswith('/'):
39
+ url = url[:-1]
40
+
41
+ if not url.startswith('https://'):
42
+ raise ValueError(f'Url must be https, is {url}')
43
+
44
+ self._url = url
45
+ self.verify_ssl = verify_ssl
46
+
47
+ if active_session is None:
48
+ self._current_session = requests.Session()
49
+ else:
50
+ self._current_session = active_session
51
+
52
+ self._auth_method: Optional[IAuthenticationMethod] = None
53
+ self._cookies = requests.cookies.RequestsCookieJar()
54
+
55
+ self._modules: dict[str, _ModuleBase] = {}
56
+
57
+ def url(self) -> str:
58
+ return self._url
59
+
60
+ def credentials_key(self) -> str:
61
+ """
62
+ Generate a unique key that identifies the current credentials set.
63
+ """
64
+ if self._auth_method is None:
65
+ raise MercutoClientException("No credentials set")
66
+ return self._auth_method.unique_key()
67
+
68
+ def setverify_ssl(self, verify_ssl: bool) -> None:
69
+ self.verify_ssl = verify_ssl
70
+
71
+ def copy(self) -> 'MercutoClient':
72
+ return MercutoClient(self._url, self.verify_ssl, self._current_session)
73
+
74
+ @contextlib.contextmanager
75
+ def as_credentials(self, api_key: Optional[str] = None,
76
+ service_token: Optional[str] = None,
77
+ bearer_token: Optional[str] = None,
78
+ headers: Optional[Mapping[str, str]] = None) -> Iterator['MercutoClient']:
79
+ """
80
+ Same as .connect(), but as a context manager. Will automatically logout when exiting the context.
81
+ """
82
+ # TODO: We are passing the current session along to re-use connections for speed. Will this cause security issues?
83
+ other = MercutoClient(self._url, self.verify_ssl, self._current_session)
84
+ try:
85
+ yield other.connect(api_key=api_key, service_token=service_token, bearer_token=bearer_token, headers=headers)
86
+ finally:
87
+ other.logout()
88
+
89
+ def connect(self, *, api_key: Optional[str] = None,
90
+ service_token: Optional[str] = None,
91
+ bearer_token: Optional[str] = None,
92
+ headers: Optional[Mapping[str, str]] = None) -> 'MercutoClient':
93
+ """
94
+ Attempt to connect using any available method.
95
+ if api_key is provided, use the api_key.
96
+ if service_token is provided, use the service_token.
97
+ if headers is provided, attempt to extract either api_key or service_token from given header set.
98
+ headers should be a dictionary of headers that would be sent in a request. Useful for using existing authenation mechanism for forwarding.
99
+
100
+ """
101
+ authentication = create_authentication_method(api_key=api_key, service_token=service_token, bearer_token=bearer_token, headers=headers)
102
+ self.login(authentication)
103
+ return self
104
+
105
+ def _update_headers(self, headers: dict[str, str]) -> dict[str, str]:
106
+ base: dict[str, str] = {}
107
+
108
+ if self._auth_method is not None:
109
+ self._auth_method.update_header(base)
110
+ base.update(headers)
111
+ return base
112
+
113
+ def session(self) -> requests.Session:
114
+ return self._current_session
115
+
116
+ def request(self, url: str, method: Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'],
117
+ params: Optional[dict[str, Any]] = None,
118
+ json: Optional[dict[str, Any]] = None,
119
+ raise_for_status: bool = True,
120
+ **kwargs: Any) -> requests.Response:
121
+ """
122
+ Make an HTTP request to the Mercuto API.
123
+ :param url: The URL path (relative to the base API URL) to make the request to.
124
+ :param method: The HTTP method to use (e.g., 'GET', 'POST', etc.).
125
+ :param params: Optional dictionary of query parameters to include in the request.
126
+ :param json: Optional dictionary to send as a JSON payload in the request body.
127
+ :param raise_for_status: Whether to raise an exception for HTTP error responses.
128
+ :param kwargs: Additional keyword arguments to pass to the requests method.
129
+ :return: The HTTP response object.
130
+ """
131
+ return self._http_request(url, method, params=params, json=json, raise_for_status=raise_for_status, **kwargs)
132
+
133
+ def _http_request(self, url: str, method: str,
134
+ params: Optional[dict[str, Any]] = None,
135
+ json: Optional[dict[str, Any]] = None,
136
+ raise_for_status: bool = True,
137
+ **kwargs: Any) -> requests.Response:
138
+ if url.startswith('/'):
139
+ url = url[1:]
140
+ full_url = f"{self._url}/{url}"
141
+
142
+ if 'timeout' not in kwargs:
143
+ kwargs['timeout'] = 10
144
+ kwargs['headers'] = self._update_headers(kwargs.get('headers', {}))
145
+
146
+ if 'verify' not in kwargs:
147
+ kwargs['verify'] = self.verify_ssl
148
+
149
+ if 'cookies' not in kwargs:
150
+ kwargs['cookies'] = self._cookies
151
+
152
+ # Custom parsing json to support NAN
153
+ if json is not None and kwargs.get('data') is None:
154
+ kwargs['data'] = json_stdlib.dumps(json, allow_nan=True)
155
+ kwargs['headers']['Content-Type'] = 'application/json'
156
+ json = None
157
+
158
+ start = time.time()
159
+ resp = self._current_session.request(method, full_url, params=params, json=json, **kwargs)
160
+ duration = time.time() - start
161
+ logger.debug("Made request to %s %s in %.2f seconds (code=%s)", method, full_url, duration, resp.status_code)
162
+ if raise_for_status and not resp.ok:
163
+ try:
164
+ error_json = resp.json()
165
+ except Exception:
166
+ raise MercutoHTTPException(resp.text, resp.status_code)
167
+ else:
168
+ if 'detail' in error_json and isinstance(error_json['detail'], str):
169
+ raise MercutoHTTPException(error_json['detail'], resp.status_code)
170
+ else:
171
+ raise MercutoHTTPException(resp.text, resp.status_code)
172
+ resp.cookies.update(self._cookies)
173
+ return resp
174
+
175
+ def _add_and_fetch_module(self, name: str, module: Type[_T]) -> _T:
176
+ if name not in self._modules:
177
+ self._modules[name] = module(self)
178
+ return self._modules[name] # type: ignore
179
+
180
+ def identity(self) -> 'MercutoIdentityService':
181
+ return self._add_and_fetch_module('identity', MercutoIdentityService)
182
+
183
+ def fatigue(self) -> 'MercutoFatigueService':
184
+ return self._add_and_fetch_module('fatigue', MercutoFatigueService)
185
+
186
+ def data(self) -> 'MercutoDataService':
187
+ return self._add_and_fetch_module('data', MercutoDataService)
188
+
189
+ def core(self) -> 'MercutoCoreService':
190
+ return self._add_and_fetch_module('core', MercutoCoreService)
191
+
192
+ def media(self) -> 'MercutoMediaService':
193
+ return self._add_and_fetch_module('media', MercutoMediaService)
194
+
195
+ def login(self, authentication: IAuthenticationMethod) -> None:
196
+ self._auth_method = authentication
197
+
198
+ def logout(self) -> None:
199
+ self._auth_method = None
200
+
201
+ def is_logged_in(self) -> bool:
202
+ return self._auth_method is not None
@@ -1,4 +1,5 @@
1
1
  import json
2
+ from typing import Any
2
3
 
3
4
 
4
5
  class MercutoClientException(Exception):
@@ -11,5 +12,8 @@ class MercutoHTTPException(MercutoClientException):
11
12
  self.status_code = status_code
12
13
  self.message = message
13
14
 
14
- def json(self) -> dict:
15
+ def json(self) -> Any:
15
16
  return json.loads(self.message)
17
+
18
+ def __str__(self) -> str:
19
+ return f"MercutoHTTPException(status_code='{self.status_code}', message='{self.message}')"