mercuto-client 0.2.7__tar.gz → 0.3.0a0__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 (55) hide show
  1. mercuto_client-0.3.0a0/PKG-INFO +72 -0
  2. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/README.md +11 -3
  3. mercuto_client-0.3.0a0/mercuto_client/__init__.py +8 -0
  4. mercuto_client-0.3.0a0/mercuto_client/_authentication.py +72 -0
  5. mercuto_client-0.3.0a0/mercuto_client/_tests/test_ingester/test_parsers.py +145 -0
  6. mercuto_client-0.3.0a0/mercuto_client/_tests/test_mocking/conftest.py +13 -0
  7. mercuto_client-0.3.0a0/mercuto_client/_tests/test_mocking/test_mock_identity.py +8 -0
  8. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/acl.py +16 -10
  9. mercuto_client-0.3.0a0/mercuto_client/client.py +177 -0
  10. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/exceptions.py +5 -1
  11. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/ingester/__main__.py +1 -1
  12. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/ingester/mercuto.py +15 -16
  13. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/ingester/parsers/__init__.py +3 -3
  14. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/ingester/parsers/campbell.py +2 -2
  15. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/ingester/parsers/generic_csv.py +5 -5
  16. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/ingester/parsers/worldsensing.py +4 -3
  17. mercuto_client-0.3.0a0/mercuto_client/mocks/__init__.py +92 -0
  18. mercuto_client-0.3.0a0/mercuto_client/mocks/_utility.py +69 -0
  19. mercuto_client-0.3.0a0/mercuto_client/mocks/mock_data.py +402 -0
  20. mercuto_client-0.3.0a0/mercuto_client/mocks/mock_fatigue.py +30 -0
  21. mercuto_client-0.3.0a0/mercuto_client/mocks/mock_identity.py +188 -0
  22. mercuto_client-0.3.0a0/mercuto_client/modules/__init__.py +19 -0
  23. mercuto_client-0.3.0a0/mercuto_client/modules/_util.py +18 -0
  24. mercuto_client-0.3.0a0/mercuto_client/modules/core.py +674 -0
  25. mercuto_client-0.3.0a0/mercuto_client/modules/data.py +623 -0
  26. mercuto_client-0.3.0a0/mercuto_client/modules/fatigue.py +189 -0
  27. mercuto_client-0.3.0a0/mercuto_client/modules/identity.py +254 -0
  28. mercuto_client-0.3.0a0/mercuto_client/py.typed +0 -0
  29. {mercuto_client-0.2.7/mercuto_client/ingester → mercuto_client-0.3.0a0/mercuto_client}/util.py +27 -11
  30. mercuto_client-0.3.0a0/mercuto_client.egg-info/PKG-INFO +72 -0
  31. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client.egg-info/SOURCES.txt +17 -6
  32. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client.egg-info/requires.txt +1 -0
  33. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/pyproject.toml +11 -5
  34. mercuto_client-0.2.7/PKG-INFO +0 -20
  35. mercuto_client-0.2.7/mercuto_client/__init__.py +0 -30
  36. mercuto_client-0.2.7/mercuto_client/_tests/test_ingester/test_parsers.py +0 -145
  37. mercuto_client-0.2.7/mercuto_client/_tests/test_mocking.py +0 -93
  38. mercuto_client-0.2.7/mercuto_client/_util.py +0 -13
  39. mercuto_client-0.2.7/mercuto_client/client.py +0 -903
  40. mercuto_client-0.2.7/mercuto_client/mocks.py +0 -203
  41. mercuto_client-0.2.7/mercuto_client/types.py +0 -409
  42. mercuto_client-0.2.7/mercuto_client.egg-info/PKG-INFO +0 -20
  43. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/LICENSE +0 -0
  44. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/_tests/__init__.py +0 -0
  45. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/_tests/conftest.py +0 -0
  46. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/_tests/test_ingester/__init__.py +0 -0
  47. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/_tests/test_ingester/test_file_processor.py +0 -0
  48. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/_tests/test_ingester/test_ftp.py +0 -0
  49. {mercuto_client-0.2.7/mercuto_client/ingester → mercuto_client-0.3.0a0/mercuto_client/_tests/test_mocking}/__init__.py +0 -0
  50. /mercuto_client-0.2.7/mercuto_client/py.typed → /mercuto_client-0.3.0a0/mercuto_client/ingester/__init__.py +0 -0
  51. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/ingester/ftp.py +0 -0
  52. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client/ingester/processor.py +0 -0
  53. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client.egg-info/dependency_links.txt +0 -0
  54. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/mercuto_client.egg-info/top_level.txt +0 -0
  55. {mercuto_client-0.2.7 → mercuto_client-0.3.0a0}/setup.cfg +0 -0
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: mercuto-client
3
+ Version: 0.3.0a0
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,6 +3,8 @@
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
9
  Install from PyPi: `pip install mercuto-client` or adding the same line into your `requirements.txt`.
8
10
 
@@ -14,7 +16,7 @@ Use the `connect()` function exposed within the main package and provide your AP
14
16
  from mercuto_client import connect
15
17
 
16
18
  client = connect(api_key="<YOUR API KEY>")
17
- print(client.projects().get_projects())
19
+ print(client.core().list_projects())
18
20
 
19
21
  # Logout after finished.
20
22
  client.logout()
@@ -27,11 +29,17 @@ You can also use the client as a context manager. It will logout automatically.
27
29
  from mercuto_client import MercutoClient
28
30
 
29
31
  with MercutoClient.as_credentials(api_key='<YOUR API KEY>') as client:
30
- print(client.projects().get_projects())
32
+ print(client.core().list_projects())
31
33
  ```
32
34
 
33
35
  ## Current Status
34
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.
35
37
 
36
38
  - [x] API Based login (Completed)
37
- - [ ] 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()
@@ -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,177 @@
1
+ import contextlib
2
+ import json as json_stdlib
3
+ import logging
4
+ import os
5
+ import time
6
+ from typing import Any, Iterator, Mapping, Optional, Protocol, Type, TypeVar
7
+
8
+ import requests
9
+ import requests.cookies
10
+
11
+ from ._authentication import (IAuthenticationMethod,
12
+ create_authentication_method)
13
+ from .exceptions import MercutoClientException, MercutoHTTPException
14
+ from .modules.core import MercutoCoreService
15
+ from .modules.data import MercutoDataService
16
+ from .modules.fatigue import MercutoFatigueService
17
+ from .modules.identity import MercutoIdentityService
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class _ModuleBase(Protocol):
23
+ def __init__(self, client: 'MercutoClient', *args: Any, **kwargs: Any) -> None:
24
+ pass
25
+
26
+
27
+ _T = TypeVar('_T', bound=_ModuleBase)
28
+
29
+
30
+ class MercutoClient:
31
+ def __init__(self, url: Optional[str] = None, verify_ssl: bool = True, active_session: Optional[requests.Session] = None) -> None:
32
+ if url is None:
33
+ url = os.environ.get('MERCUTO_API_URL', 'https://api.rockfieldcloud.com.au')
34
+ assert isinstance(url, str)
35
+
36
+ if url.endswith('/'):
37
+ url = url[:-1]
38
+
39
+ if not url.startswith('https://'):
40
+ raise ValueError(f'Url must be https, is {url}')
41
+
42
+ self._url = url
43
+ self._verify_ssl = verify_ssl
44
+
45
+ if active_session is None:
46
+ self._current_session = requests.Session()
47
+ else:
48
+ self._current_session = active_session
49
+
50
+ self._auth_method: Optional[IAuthenticationMethod] = None
51
+ self._cookies = requests.cookies.RequestsCookieJar()
52
+
53
+ self._modules: dict[str, _ModuleBase] = {}
54
+
55
+ def url(self) -> str:
56
+ return self._url
57
+
58
+ def credentials_key(self) -> str:
59
+ """
60
+ Generate a unique key that identifies the current credentials set.
61
+ """
62
+ if self._auth_method is None:
63
+ raise MercutoClientException("No credentials set")
64
+ return self._auth_method.unique_key()
65
+
66
+ def set_verify_ssl(self, verify_ssl: bool) -> None:
67
+ self._verify_ssl = verify_ssl
68
+
69
+ def copy(self) -> 'MercutoClient':
70
+ return MercutoClient(self._url, self._verify_ssl, self._current_session)
71
+
72
+ @contextlib.contextmanager
73
+ def as_credentials(self, api_key: Optional[str] = None,
74
+ service_token: Optional[str] = None,
75
+ bearer_token: Optional[str] = None,
76
+ headers: Optional[Mapping[str, str]] = None) -> Iterator['MercutoClient']:
77
+ """
78
+ Same as .connect(), but as a context manager. Will automatically logout when exiting the context.
79
+ """
80
+ # TODO: We are passing the current session along to re-use connections for speed. Will this cause security issues?
81
+ other = MercutoClient(self._url, self._verify_ssl, self._current_session)
82
+ try:
83
+ yield other.connect(api_key=api_key, service_token=service_token, bearer_token=bearer_token, headers=headers)
84
+ finally:
85
+ other.logout()
86
+
87
+ def connect(self, *, api_key: Optional[str] = None,
88
+ service_token: Optional[str] = None,
89
+ bearer_token: Optional[str] = None,
90
+ headers: Optional[Mapping[str, str]] = None) -> 'MercutoClient':
91
+ """
92
+ Attempt to connect using any available method.
93
+ if api_key is provided, use the api_key.
94
+ if service_token is provided, use the service_token.
95
+ if headers is provided, attempt to extract either api_key or service_token from given header set.
96
+ headers should be a dictionary of headers that would be sent in a request. Useful for using existing authenation mechanism for forwarding.
97
+
98
+ """
99
+ authentication = create_authentication_method(api_key=api_key, service_token=service_token, bearer_token=bearer_token, headers=headers)
100
+ self.login(authentication)
101
+ return self
102
+
103
+ def _update_headers(self, headers: dict[str, str]) -> dict[str, str]:
104
+ base: dict[str, str] = {}
105
+
106
+ if self._auth_method is not None:
107
+ self._auth_method.update_header(base)
108
+ base.update(headers)
109
+ return base
110
+
111
+ def _http_request(self, url: str, method: str,
112
+ params: Optional[dict[str, Any]] = None,
113
+ json: Optional[dict[str, Any]] = None,
114
+ raise_for_status: bool = True,
115
+ **kwargs: Any) -> requests.Response:
116
+ if url.startswith('/'):
117
+ url = url[1:]
118
+ full_url = f"{self._url}/{url}"
119
+
120
+ if 'timeout' not in kwargs:
121
+ kwargs['timeout'] = 10
122
+ kwargs['headers'] = self._update_headers(kwargs.get('headers', {}))
123
+
124
+ if 'verify' not in kwargs:
125
+ kwargs['verify'] = self._verify_ssl
126
+
127
+ if 'cookies' not in kwargs:
128
+ kwargs['cookies'] = self._cookies
129
+
130
+ # Custom parsing json to support NAN
131
+ if json is not None and kwargs.get('data') is None:
132
+ kwargs['data'] = json_stdlib.dumps(json, allow_nan=True)
133
+ kwargs['headers']['Content-Type'] = 'application/json'
134
+ json = None
135
+
136
+ start = time.time()
137
+ resp = self._current_session.request(method, full_url, params=params, json=json, **kwargs)
138
+ duration = time.time() - start
139
+ logger.debug("Made request to %s %s in %.2f seconds (code=%s)", method, full_url, duration, resp.status_code)
140
+ if raise_for_status and not resp.ok:
141
+ try:
142
+ error_json = resp.json()
143
+ except Exception:
144
+ raise MercutoHTTPException(resp.text, resp.status_code)
145
+ else:
146
+ if 'detail' in error_json and isinstance(error_json['detail'], str):
147
+ raise MercutoHTTPException(error_json['detail'], resp.status_code)
148
+ else:
149
+ raise MercutoHTTPException(resp.text, resp.status_code)
150
+ resp.cookies.update(self._cookies)
151
+ return resp
152
+
153
+ def _add_and_fetch_module(self, name: str, module: Type[_T]) -> _T:
154
+ if name not in self._modules:
155
+ self._modules[name] = module(self)
156
+ return self._modules[name] # type: ignore
157
+
158
+ def identity(self) -> 'MercutoIdentityService':
159
+ return self._add_and_fetch_module('identity', MercutoIdentityService)
160
+
161
+ def fatigue(self) -> 'MercutoFatigueService':
162
+ return self._add_and_fetch_module('fatigue', MercutoFatigueService)
163
+
164
+ def data(self) -> 'MercutoDataService':
165
+ return self._add_and_fetch_module('data', MercutoDataService)
166
+
167
+ def core(self) -> 'MercutoCoreService':
168
+ return self._add_and_fetch_module('core', MercutoCoreService)
169
+
170
+ def login(self, authentication: IAuthenticationMethod) -> None:
171
+ self._auth_method = authentication
172
+
173
+ def logout(self) -> None:
174
+ self._auth_method = None
175
+
176
+ def is_logged_in(self) -> bool:
177
+ 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}')"
@@ -8,10 +8,10 @@ from typing import Callable, TypeVar
8
8
 
9
9
  import schedule
10
10
 
11
+ from ..util import get_free_space_excluding_files
11
12
  from .ftp import simple_ftp_server
12
13
  from .mercuto import MercutoIngester
13
14
  from .processor import FileProcessor
14
- from .util import get_free_space_excluding_files
15
15
 
16
16
  logger = logging.getLogger(__name__)
17
17