ngilive 0.1.0__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.
- ngilive-0.1.0/PKG-INFO +165 -0
- ngilive-0.1.0/README.md +149 -0
- ngilive-0.1.0/pyproject.toml +23 -0
- ngilive-0.1.0/src/ngilive/__init__.py +4 -0
- ngilive-0.1.0/src/ngilive/api.py +247 -0
- ngilive-0.1.0/src/ngilive/auth.py +386 -0
- ngilive-0.1.0/src/ngilive/config.py +6 -0
- ngilive-0.1.0/src/ngilive/httpx_wrapper.py +61 -0
- ngilive-0.1.0/src/ngilive/log.py +13 -0
- ngilive-0.1.0/src/ngilive/terminal_helpers.py +21 -0
ngilive-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: ngilive
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Ole-Jakob Olsen
|
|
6
|
+
Author-email: ole.jakob.olsen@ngi.no
|
|
7
|
+
Requires-Python: >=3.11,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
13
|
+
Requires-Dist: pydantic (>=2.11.9,<3.0.0)
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# NGILIVE SDK
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
Useful library to develop against the NGI Live API.
|
|
21
|
+
|
|
22
|
+
It helps you get access to the API by doing all the difficult auth things.
|
|
23
|
+
|
|
24
|
+
Additionally it provides nice type hinted bindings for the API endpoints,
|
|
25
|
+
so you can follow code completion instead of reading documentation!
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from ngilive import API
|
|
29
|
+
|
|
30
|
+
api = API()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
sensor_response = api.query_sensors(
|
|
34
|
+
20190539,
|
|
35
|
+
logger="IK50",
|
|
36
|
+
unit="V",
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The first time you run it, you will see an output like this in your terminal.
|
|
41
|
+
Perform the log in as prompted, and you will not see it again until your access has
|
|
42
|
+
expired.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
[18:41:13] ngilive.auth INFO: Please complete the authentication in your browser: https://keycloak.ngiapi.no/auth/...
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Example Queries
|
|
49
|
+
|
|
50
|
+
#### Query Sensor Metadata
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from ngilive import API
|
|
54
|
+
|
|
55
|
+
api = API()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
sensor_response = api.query_sensors(
|
|
59
|
+
20190539,
|
|
60
|
+
logger="IK50",
|
|
61
|
+
unit="V",
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Example response:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"sensors": [
|
|
70
|
+
{
|
|
71
|
+
"name": "18V_IK50",
|
|
72
|
+
"unit": "V",
|
|
73
|
+
"logger": "IK50",
|
|
74
|
+
"type": "zBat18V",
|
|
75
|
+
"pos": {
|
|
76
|
+
"north": null,
|
|
77
|
+
"east": null,
|
|
78
|
+
"mash": null,
|
|
79
|
+
"coordinateSystem": {
|
|
80
|
+
"authority": "EPSG",
|
|
81
|
+
"srid": null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "3V_IK50",
|
|
87
|
+
"unit": "V",
|
|
88
|
+
"logger": "IK50",
|
|
89
|
+
"type": "zBat3V",
|
|
90
|
+
"pos": {
|
|
91
|
+
"north": null,
|
|
92
|
+
"east": null,
|
|
93
|
+
"mash": null,
|
|
94
|
+
"coordinateSystem": {
|
|
95
|
+
"authority": "EPSG",
|
|
96
|
+
"srid": null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
#### Query datapoints
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
datapoints = api.query_datapoints(
|
|
108
|
+
project_number=20190539,
|
|
109
|
+
start=datetime.now(tz=UTC) - timedelta(days=1),
|
|
110
|
+
end=datetime.now(tz=UTC),
|
|
111
|
+
logger="IK50",
|
|
112
|
+
unit="V",
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Authentication
|
|
117
|
+
|
|
118
|
+
#### Authorization Code
|
|
119
|
+
|
|
120
|
+
You can use this library to obtain an access token and call the API.
|
|
121
|
+
It will open the browser for you, and ask you to log in to geohub.
|
|
122
|
+
|
|
123
|
+
The below example is useful if you want to control the HTTP client yourself, for
|
|
124
|
+
example using `requests` or `httpx` libraries.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
import httpx
|
|
128
|
+
|
|
129
|
+
from ngilive.auth import AuthorizationCode
|
|
130
|
+
|
|
131
|
+
auth = AuthorizationCode()
|
|
132
|
+
access_token = auth.get_token()
|
|
133
|
+
|
|
134
|
+
response = httpx.get(
|
|
135
|
+
"http://api.test.ngilive.no/projects/20190539/sensors",
|
|
136
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
#### Client Credentials
|
|
141
|
+
|
|
142
|
+
This example uses client_id and client secret instead of signing in
|
|
143
|
+
in the browser. It is useful in cases where an automatic job should
|
|
144
|
+
call the API, which cannot log in via the browser. For other usecases,
|
|
145
|
+
use AuthorizationCode instead.
|
|
146
|
+
|
|
147
|
+
You can also use the ClientCredentials helper to get an access token
|
|
148
|
+
like in the above Authorization code example.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from ngilive import API
|
|
152
|
+
from ngilive.auth import ClientCredentials
|
|
153
|
+
|
|
154
|
+
auth = ClientCredentials(
|
|
155
|
+
client_id="data-api-test-client",
|
|
156
|
+
client_secret="<client secret>",
|
|
157
|
+
loglevel="DEBUG",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
api = API(auth=auth)
|
|
161
|
+
|
|
162
|
+
# Now you can query the API without logging in
|
|
163
|
+
# sensor_response = api.query_sensors(20190539)
|
|
164
|
+
```
|
|
165
|
+
|
ngilive-0.1.0/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# NGILIVE SDK
|
|
2
|
+
|
|
3
|
+
## Usage
|
|
4
|
+
|
|
5
|
+
Useful library to develop against the NGI Live API.
|
|
6
|
+
|
|
7
|
+
It helps you get access to the API by doing all the difficult auth things.
|
|
8
|
+
|
|
9
|
+
Additionally it provides nice type hinted bindings for the API endpoints,
|
|
10
|
+
so you can follow code completion instead of reading documentation!
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from ngilive import API
|
|
14
|
+
|
|
15
|
+
api = API()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
sensor_response = api.query_sensors(
|
|
19
|
+
20190539,
|
|
20
|
+
logger="IK50",
|
|
21
|
+
unit="V",
|
|
22
|
+
)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The first time you run it, you will see an output like this in your terminal.
|
|
26
|
+
Perform the log in as prompted, and you will not see it again until your access has
|
|
27
|
+
expired.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
[18:41:13] ngilive.auth INFO: Please complete the authentication in your browser: https://keycloak.ngiapi.no/auth/...
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Example Queries
|
|
34
|
+
|
|
35
|
+
#### Query Sensor Metadata
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from ngilive import API
|
|
39
|
+
|
|
40
|
+
api = API()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
sensor_response = api.query_sensors(
|
|
44
|
+
20190539,
|
|
45
|
+
logger="IK50",
|
|
46
|
+
unit="V",
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Example response:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"sensors": [
|
|
55
|
+
{
|
|
56
|
+
"name": "18V_IK50",
|
|
57
|
+
"unit": "V",
|
|
58
|
+
"logger": "IK50",
|
|
59
|
+
"type": "zBat18V",
|
|
60
|
+
"pos": {
|
|
61
|
+
"north": null,
|
|
62
|
+
"east": null,
|
|
63
|
+
"mash": null,
|
|
64
|
+
"coordinateSystem": {
|
|
65
|
+
"authority": "EPSG",
|
|
66
|
+
"srid": null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "3V_IK50",
|
|
72
|
+
"unit": "V",
|
|
73
|
+
"logger": "IK50",
|
|
74
|
+
"type": "zBat3V",
|
|
75
|
+
"pos": {
|
|
76
|
+
"north": null,
|
|
77
|
+
"east": null,
|
|
78
|
+
"mash": null,
|
|
79
|
+
"coordinateSystem": {
|
|
80
|
+
"authority": "EPSG",
|
|
81
|
+
"srid": null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### Query datapoints
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
datapoints = api.query_datapoints(
|
|
93
|
+
project_number=20190539,
|
|
94
|
+
start=datetime.now(tz=UTC) - timedelta(days=1),
|
|
95
|
+
end=datetime.now(tz=UTC),
|
|
96
|
+
logger="IK50",
|
|
97
|
+
unit="V",
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Authentication
|
|
102
|
+
|
|
103
|
+
#### Authorization Code
|
|
104
|
+
|
|
105
|
+
You can use this library to obtain an access token and call the API.
|
|
106
|
+
It will open the browser for you, and ask you to log in to geohub.
|
|
107
|
+
|
|
108
|
+
The below example is useful if you want to control the HTTP client yourself, for
|
|
109
|
+
example using `requests` or `httpx` libraries.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
import httpx
|
|
113
|
+
|
|
114
|
+
from ngilive.auth import AuthorizationCode
|
|
115
|
+
|
|
116
|
+
auth = AuthorizationCode()
|
|
117
|
+
access_token = auth.get_token()
|
|
118
|
+
|
|
119
|
+
response = httpx.get(
|
|
120
|
+
"http://api.test.ngilive.no/projects/20190539/sensors",
|
|
121
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
#### Client Credentials
|
|
126
|
+
|
|
127
|
+
This example uses client_id and client secret instead of signing in
|
|
128
|
+
in the browser. It is useful in cases where an automatic job should
|
|
129
|
+
call the API, which cannot log in via the browser. For other usecases,
|
|
130
|
+
use AuthorizationCode instead.
|
|
131
|
+
|
|
132
|
+
You can also use the ClientCredentials helper to get an access token
|
|
133
|
+
like in the above Authorization code example.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from ngilive import API
|
|
137
|
+
from ngilive.auth import ClientCredentials
|
|
138
|
+
|
|
139
|
+
auth = ClientCredentials(
|
|
140
|
+
client_id="data-api-test-client",
|
|
141
|
+
client_secret="<client secret>",
|
|
142
|
+
loglevel="DEBUG",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
api = API(auth=auth)
|
|
146
|
+
|
|
147
|
+
# Now you can query the API without logging in
|
|
148
|
+
# sensor_response = api.query_sensors(20190539)
|
|
149
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ngilive"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = ""
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Ole-Jakob Olsen",email = "ole.jakob.olsen@ngi.no"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.11,<4.0"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
[tool.poetry]
|
|
13
|
+
packages = [
|
|
14
|
+
{ include = "ngilive", from = "src" },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[tool.poetry.dependencies]
|
|
18
|
+
httpx = "^0.28.1"
|
|
19
|
+
pydantic = "^2.11.9"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
23
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, ParamSpec
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from pydantic import AwareDatetime, BaseModel
|
|
8
|
+
|
|
9
|
+
from ngilive.auth import Auth, AuthorizationCode
|
|
10
|
+
from ngilive.config import BASE_URL
|
|
11
|
+
from ngilive.httpx_wrapper import HTTPXWrapper
|
|
12
|
+
from ngilive.log import default_handler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventResponse(BaseModel):
|
|
16
|
+
event_id: UUID
|
|
17
|
+
time_from: AwareDatetime
|
|
18
|
+
time_to: AwareDatetime | None = None
|
|
19
|
+
type: str
|
|
20
|
+
tags: list[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CoordinateSystem(BaseModel):
|
|
24
|
+
authority: str
|
|
25
|
+
srid: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SensorLocation(BaseModel):
|
|
29
|
+
north: float | None = None
|
|
30
|
+
east: float | None = None
|
|
31
|
+
mash: float | None = None
|
|
32
|
+
coordinateSystem: CoordinateSystem | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SensorMeta(BaseModel):
|
|
36
|
+
name: str | None = None
|
|
37
|
+
unit: str | None = None
|
|
38
|
+
logger: str | None = None
|
|
39
|
+
type: str
|
|
40
|
+
pos: SensorLocation
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SensorMetaResponse(BaseModel):
|
|
44
|
+
sensors: list[SensorMeta]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SensorName(BaseModel):
|
|
48
|
+
name: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Datapoint(BaseModel):
|
|
52
|
+
timestamp: AwareDatetime
|
|
53
|
+
value: float
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class JsonData(BaseModel):
|
|
57
|
+
sensor: SensorName
|
|
58
|
+
data: list[Datapoint]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class JsonDataResponse(BaseModel):
|
|
62
|
+
# {
|
|
63
|
+
# "data": [
|
|
64
|
+
# {
|
|
65
|
+
# "sensor": {
|
|
66
|
+
# "name": "string"
|
|
67
|
+
# },
|
|
68
|
+
# "data": [
|
|
69
|
+
# {
|
|
70
|
+
# "timestamp": "2025-10-04T15:26:34.172Z",
|
|
71
|
+
# "value": 0
|
|
72
|
+
# }
|
|
73
|
+
# ]
|
|
74
|
+
# }
|
|
75
|
+
# ]
|
|
76
|
+
# }
|
|
77
|
+
|
|
78
|
+
data: list[JsonData]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
P = ParamSpec("P")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class API:
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
base_url: str = BASE_URL,
|
|
88
|
+
loglevel: str = "INFO",
|
|
89
|
+
auth: Auth | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._logger = logging.getLogger("ngilive.api")
|
|
92
|
+
self._logger.setLevel(loglevel)
|
|
93
|
+
if not self._logger.handlers:
|
|
94
|
+
self._logger.addHandler(default_handler())
|
|
95
|
+
|
|
96
|
+
self._c = httpx.Client()
|
|
97
|
+
self._base = base_url
|
|
98
|
+
self._logger.debug(f"Initialized api with base url {base_url}")
|
|
99
|
+
|
|
100
|
+
if auth is not None:
|
|
101
|
+
self._logger.debug(f"Using user specified auth provider {type(auth)}")
|
|
102
|
+
self._auth = auth
|
|
103
|
+
else:
|
|
104
|
+
self._auth = AuthorizationCode(loglevel=loglevel)
|
|
105
|
+
self._logger.debug(f"Using default Auth provider {type(self._auth)}")
|
|
106
|
+
|
|
107
|
+
self._httpx = HTTPXWrapper(loglevel)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def base_url(self):
|
|
111
|
+
return self._base
|
|
112
|
+
|
|
113
|
+
def get_token(self) -> str:
|
|
114
|
+
return self._auth.get_token()
|
|
115
|
+
|
|
116
|
+
def query_sensors(
|
|
117
|
+
self,
|
|
118
|
+
project: int,
|
|
119
|
+
name: str | list[str] | None = None,
|
|
120
|
+
type: str | list[str] | None = None,
|
|
121
|
+
unit: str | list[str] | None = None,
|
|
122
|
+
logger: str | list[str] | None = None,
|
|
123
|
+
) -> SensorMetaResponse:
|
|
124
|
+
"""Retrieve sensors within a project.
|
|
125
|
+
|
|
126
|
+
This endpoint returns the sensors configured for a given project.
|
|
127
|
+
The response can be filtered by sensor name, type, unit, or logger.
|
|
128
|
+
Note that the same sensor may exist in multiple loggers.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
project (int):
|
|
132
|
+
Project number.
|
|
133
|
+
name (str | list[str] | None, optional):
|
|
134
|
+
Filters response by sensor name. Note that the same sensor might exist in multiple loggers.
|
|
135
|
+
type (str | list[str] | None, optional):
|
|
136
|
+
Filter by sensor type (e.g., ``"Infiltrasjonstrykk"``).
|
|
137
|
+
unit (str | list[str] | None, optional):
|
|
138
|
+
Filter by configured sensor unit (e.g., ``"mm"``, ``"kPa"``).
|
|
139
|
+
logger (str | list[str] | None, optional):
|
|
140
|
+
Filters the response by logger name.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
params = {}
|
|
144
|
+
if name is not None:
|
|
145
|
+
params["name"] = name
|
|
146
|
+
|
|
147
|
+
if type is not None:
|
|
148
|
+
params["type"] = type
|
|
149
|
+
|
|
150
|
+
if unit is not None:
|
|
151
|
+
params["unit"] = unit
|
|
152
|
+
|
|
153
|
+
if logger is not None:
|
|
154
|
+
params["logger"] = logger
|
|
155
|
+
|
|
156
|
+
res = self._httpx.get(
|
|
157
|
+
f"{self._base}/projects/{project}/sensors",
|
|
158
|
+
params=params,
|
|
159
|
+
headers={"Authorization": f"Bearer {self.get_token()}"},
|
|
160
|
+
)
|
|
161
|
+
res.raise_for_status()
|
|
162
|
+
|
|
163
|
+
return SensorMetaResponse.model_validate(res.json())
|
|
164
|
+
|
|
165
|
+
def query_datapoints(
|
|
166
|
+
self,
|
|
167
|
+
project_number: int,
|
|
168
|
+
start: datetime,
|
|
169
|
+
end: datetime,
|
|
170
|
+
offset: int | None = None,
|
|
171
|
+
limit: int | None = None,
|
|
172
|
+
name: str | list[str] | None = None,
|
|
173
|
+
type: str | list[str] | None = None,
|
|
174
|
+
unit: str | list[str] | None = None,
|
|
175
|
+
logger: str | list[str] | None = None,
|
|
176
|
+
) -> JsonDataResponse:
|
|
177
|
+
"""Retrieve datapoints within a project.
|
|
178
|
+
|
|
179
|
+
This endpoint returns datapoints for a given project in the specified
|
|
180
|
+
time interval. Results can be paginated using ``offset`` and ``limit``,
|
|
181
|
+
and filtered by sensor attributes such as name, type, unit, or logger.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
project_number (int):
|
|
185
|
+
Project number.
|
|
186
|
+
start (datetime):
|
|
187
|
+
Start time of datapoints time series.
|
|
188
|
+
end (datetime):
|
|
189
|
+
End time of datapoints time series.
|
|
190
|
+
offset (int | None, optional):
|
|
191
|
+
The amount of points that will be skipped before returning data
|
|
192
|
+
in the query. Used in conjunction with ``limit`` when paging
|
|
193
|
+
through the data. Example: ``offset=5000&limit=2000`` will return
|
|
194
|
+
points 5000–7000.
|
|
195
|
+
limit (int | None, optional):
|
|
196
|
+
The amount of points that will be returned in the query. Used in
|
|
197
|
+
conjunction with ``offset`` when paging through the data. Example:
|
|
198
|
+
``offset=5000&limit=2000`` will return points 5000–7000.
|
|
199
|
+
name (str | list[str] | None, optional):
|
|
200
|
+
Filters response by sensor name. Note that the same sensor might
|
|
201
|
+
exist in multiple loggers.
|
|
202
|
+
type (str | list[str] | None, optional):
|
|
203
|
+
Filters the response by sensor type, for example ``"Infiltrasjonstrykk"``.
|
|
204
|
+
unit (str | list[str] | None, optional):
|
|
205
|
+
Filters the response by configured sensor unit, for example
|
|
206
|
+
``"mm"`` or ``"kPa"``.
|
|
207
|
+
logger (str | list[str] | None, optional):
|
|
208
|
+
Filters the response by logger name.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
params: dict[str, Any] = {"start": start.isoformat(), "end": end.isoformat()}
|
|
212
|
+
|
|
213
|
+
if offset is not None:
|
|
214
|
+
params["offset"] = offset
|
|
215
|
+
|
|
216
|
+
if limit is not None:
|
|
217
|
+
params["limit"] = limit
|
|
218
|
+
|
|
219
|
+
if name is not None:
|
|
220
|
+
params["name"] = name
|
|
221
|
+
|
|
222
|
+
if type is not None:
|
|
223
|
+
params["type"] = type
|
|
224
|
+
|
|
225
|
+
if unit is not None:
|
|
226
|
+
params["unit"] = unit
|
|
227
|
+
|
|
228
|
+
if logger is not None:
|
|
229
|
+
params["logger"] = logger
|
|
230
|
+
|
|
231
|
+
res = self._httpx.get(
|
|
232
|
+
f"{self._base}/projects/{project_number}/datapoints/json_array_v0",
|
|
233
|
+
params=params,
|
|
234
|
+
headers={"Authorization": f"Bearer {self.get_token()}"},
|
|
235
|
+
)
|
|
236
|
+
res.raise_for_status()
|
|
237
|
+
|
|
238
|
+
return JsonDataResponse.model_validate(res.json())
|
|
239
|
+
|
|
240
|
+
def get_event(self, event_id: UUID) -> EventResponse:
|
|
241
|
+
res = self._httpx.get(
|
|
242
|
+
f"{self._base}/event/{event_id}",
|
|
243
|
+
headers={"Authorization": f"Bearer {self.get_token()}"},
|
|
244
|
+
)
|
|
245
|
+
res.raise_for_status()
|
|
246
|
+
|
|
247
|
+
return EventResponse.model_validate(res.json())
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import html
|
|
4
|
+
import http.server
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import secrets
|
|
10
|
+
import socketserver
|
|
11
|
+
import sys
|
|
12
|
+
import textwrap
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import webbrowser
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import NotRequired, TypedDict, cast
|
|
20
|
+
from urllib.parse import urlencode
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from ngilive.config import APP_NAME, AUTHORIZE_URL, CLIENT_ID, TOKENS_URL
|
|
25
|
+
from ngilive.terminal_helpers import Terminal
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _b64url(n=32) -> str:
|
|
29
|
+
return base64.urlsafe_b64encode(secrets.token_bytes(n)).rstrip(b"=").decode()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TokenResponseBody(TypedDict):
|
|
37
|
+
access_token: str
|
|
38
|
+
refresh_token: str
|
|
39
|
+
id_token: str
|
|
40
|
+
expires_in: int
|
|
41
|
+
refresh_expires_in: int
|
|
42
|
+
expires_at: NotRequired[float]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ts(timestamp: float | int):
|
|
46
|
+
dt_local = datetime.fromtimestamp(timestamp, tz=timezone.utc).astimezone()
|
|
47
|
+
return dt_local.isoformat()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Auth(ABC):
|
|
51
|
+
def __init__(self, loglevel: str) -> None:
|
|
52
|
+
self._logger = logging.getLogger("ngilive.auth")
|
|
53
|
+
self._logger.setLevel(loglevel)
|
|
54
|
+
if not self._logger.handlers:
|
|
55
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
56
|
+
handler.setFormatter(
|
|
57
|
+
logging.Formatter(
|
|
58
|
+
"[%(asctime)s] %(name)s %(levelname)s: %(message)s",
|
|
59
|
+
datefmt="%H:%M:%S",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
self._logger.addHandler(handler)
|
|
63
|
+
|
|
64
|
+
system = platform.system()
|
|
65
|
+
|
|
66
|
+
self._logger.debug(f"Selecting cache dir for system type '{system}'")
|
|
67
|
+
|
|
68
|
+
if system == "Windows":
|
|
69
|
+
base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
|
|
70
|
+
self._user_cache_dir = os.path.join(base, APP_NAME, "Cache")
|
|
71
|
+
elif system == "Darwin":
|
|
72
|
+
self._user_cache_dir = os.path.expanduser(f"~/Library/Caches/{APP_NAME}")
|
|
73
|
+
else: # Linux / BSD / others
|
|
74
|
+
base = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
|
|
75
|
+
self._user_cache_dir = os.path.join(base, APP_NAME)
|
|
76
|
+
|
|
77
|
+
self._logger.debug(f"Selected cache dir '{self._user_cache_dir}'")
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def get_token(self) -> str: ...
|
|
81
|
+
|
|
82
|
+
def _get_cache_file(self) -> str:
|
|
83
|
+
cache_dir = self._user_cache_dir
|
|
84
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
85
|
+
return os.path.join(cache_dir, "token.json")
|
|
86
|
+
|
|
87
|
+
def _cache_tokens(self, tokens: TokenResponseBody):
|
|
88
|
+
# TODO: Better handling of secrets, should not be stored as plain text
|
|
89
|
+
# Minimally restrict permissions for created file. Ideally use keyring
|
|
90
|
+
cache_file_path = self._get_cache_file()
|
|
91
|
+
self._logger.debug(f"Caching tokens at file path: {cache_file_path}")
|
|
92
|
+
|
|
93
|
+
tokens["expires_at"] = time.time() + tokens["expires_in"]
|
|
94
|
+
|
|
95
|
+
with open(cache_file_path, "w") as f:
|
|
96
|
+
json.dump(tokens, f)
|
|
97
|
+
|
|
98
|
+
self._logger.debug(
|
|
99
|
+
f"Successfully cached tokens. Expires at {ts(tokens["expires_at"])}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _load_cached_token(self) -> str | None:
|
|
103
|
+
try:
|
|
104
|
+
cache_file_path = self._get_cache_file()
|
|
105
|
+
self._logger.debug(
|
|
106
|
+
f"Attempting to load tokens from cache. File path: {cache_file_path}"
|
|
107
|
+
)
|
|
108
|
+
with open(cache_file_path, "r") as f:
|
|
109
|
+
tokens = json.load(f)
|
|
110
|
+
|
|
111
|
+
except FileNotFoundError:
|
|
112
|
+
self._logger.debug("Tokens not loaded from cache. File not found.")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
if tokens.get("expires_at", 0) > time.time():
|
|
116
|
+
self._logger.debug(
|
|
117
|
+
f"Tokens loaded from cache. Expires at: {ts(tokens["expires_at"])}"
|
|
118
|
+
)
|
|
119
|
+
return tokens["access_token"]
|
|
120
|
+
|
|
121
|
+
self._logger.debug("Tokens not loaded from cache. Tokens expired.")
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TCPServer(socketserver.TCPServer):
|
|
126
|
+
error: str | None = None
|
|
127
|
+
auth_code: str | None = None
|
|
128
|
+
|
|
129
|
+
# This is needed for the socket server to release the bind on the
|
|
130
|
+
# port as soon as possible
|
|
131
|
+
allow_reuse_address = True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def code_handler(expected_state: str, logger):
|
|
135
|
+
class CodeHandler(http.server.BaseHTTPRequestHandler):
|
|
136
|
+
def do_GET(self):
|
|
137
|
+
query = urllib.parse.urlparse(self.path).query
|
|
138
|
+
params = urllib.parse.parse_qs(query)
|
|
139
|
+
code = params.get("code", [None])[0]
|
|
140
|
+
|
|
141
|
+
error: str = ""
|
|
142
|
+
|
|
143
|
+
if params.get("state", [None])[0] != expected_state:
|
|
144
|
+
error += "Invalid state parameter. "
|
|
145
|
+
|
|
146
|
+
if not code:
|
|
147
|
+
error = "Could not obtain code for authorization. "
|
|
148
|
+
|
|
149
|
+
if error != "":
|
|
150
|
+
self.send_response(500)
|
|
151
|
+
self.send_header("Content-type", "text/html")
|
|
152
|
+
self.end_headers()
|
|
153
|
+
self.wfile.write(
|
|
154
|
+
textwrap.dedent(f"""
|
|
155
|
+
<!DOCTYPE html>
|
|
156
|
+
<html lang="en">
|
|
157
|
+
<head>
|
|
158
|
+
<meta charset="utf-8">
|
|
159
|
+
<title>{APP_NAME} Authentication</title>
|
|
160
|
+
</head>
|
|
161
|
+
<body>
|
|
162
|
+
<p>
|
|
163
|
+
{error}.
|
|
164
|
+
You can close this tab.
|
|
165
|
+
</p>
|
|
166
|
+
</body>
|
|
167
|
+
</html>
|
|
168
|
+
""").encode("utf-8")
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
cast(TCPServer, self.server).error = error
|
|
172
|
+
else:
|
|
173
|
+
self.send_response(200)
|
|
174
|
+
self.send_header("Content-type", "text/html")
|
|
175
|
+
self.end_headers()
|
|
176
|
+
self.wfile.write(
|
|
177
|
+
textwrap.dedent(f"""
|
|
178
|
+
<!DOCTYPE html>
|
|
179
|
+
<html lang="en">
|
|
180
|
+
<head>
|
|
181
|
+
<meta charset="utf-8">
|
|
182
|
+
<title>{html.escape(APP_NAME)} Authentication</title>
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<p>
|
|
186
|
+
{html.escape(APP_NAME)} authentication successful.
|
|
187
|
+
You can close this tab.
|
|
188
|
+
</p>
|
|
189
|
+
</body>
|
|
190
|
+
</html>
|
|
191
|
+
""").encode("utf-8")
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
cast(TCPServer, self.server).auth_code = code
|
|
195
|
+
|
|
196
|
+
def log_message(self, format, *args):
|
|
197
|
+
_ = format, args
|
|
198
|
+
logger.debug(
|
|
199
|
+
f"CodeHandler: Callback received from {self.client_address[0]} with path {self.path}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return CodeHandler
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def generate_pkce_pair():
|
|
206
|
+
# RFC 7636: verifier must be 43–128 chars, unreserved URI characters
|
|
207
|
+
code_verifier = (
|
|
208
|
+
base64.urlsafe_b64encode(os.urandom(64)).rstrip(b"=").decode("utf-8")
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Compute SHA256 and base64url encode
|
|
212
|
+
code_challenge = (
|
|
213
|
+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest())
|
|
214
|
+
.rstrip(b"=")
|
|
215
|
+
.decode("utf-8")
|
|
216
|
+
)
|
|
217
|
+
return code_verifier, code_challenge
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class AuthorizationCode(Auth):
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
client_id: str = CLIENT_ID,
|
|
224
|
+
authorize_url: str = AUTHORIZE_URL,
|
|
225
|
+
tokens_url: str = TOKENS_URL,
|
|
226
|
+
timeout: int = 300, # Seconds
|
|
227
|
+
loglevel: str = "INFO",
|
|
228
|
+
) -> None:
|
|
229
|
+
self._client_id = client_id
|
|
230
|
+
self._tokens_url = tokens_url
|
|
231
|
+
self._authorize_url = authorize_url
|
|
232
|
+
self._timeout = timeout
|
|
233
|
+
|
|
234
|
+
super().__init__(loglevel)
|
|
235
|
+
|
|
236
|
+
def get_token(self) -> str:
|
|
237
|
+
token: str | None = self._load_cached_token()
|
|
238
|
+
if token is not None:
|
|
239
|
+
return token
|
|
240
|
+
|
|
241
|
+
token = ""
|
|
242
|
+
|
|
243
|
+
state = _b64url(16)
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
with TCPServer(
|
|
247
|
+
("localhost", 0), code_handler(state, self._logger)
|
|
248
|
+
) as httpd:
|
|
249
|
+
# TCP Server with port 0 lets the os allocate a free port
|
|
250
|
+
# Here we find which port it picked
|
|
251
|
+
port = httpd.server_address[1]
|
|
252
|
+
|
|
253
|
+
code_verifier, code_challenge = generate_pkce_pair()
|
|
254
|
+
|
|
255
|
+
redirect_url = f"http://localhost:{port}"
|
|
256
|
+
params = urlencode(
|
|
257
|
+
{
|
|
258
|
+
"client_id": self._client_id,
|
|
259
|
+
"redirect_uri": redirect_url,
|
|
260
|
+
"response_type": "code",
|
|
261
|
+
"scope": "email",
|
|
262
|
+
"code_challenge": code_challenge,
|
|
263
|
+
"code_challenge_method": "S256",
|
|
264
|
+
"state": state,
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
authorize_url = f"{self._authorize_url}?{params}"
|
|
268
|
+
|
|
269
|
+
threading.Thread(
|
|
270
|
+
target=webbrowser.open_new_tab, args=(authorize_url,)
|
|
271
|
+
).start()
|
|
272
|
+
|
|
273
|
+
self._logger.info(
|
|
274
|
+
"Please complete the authentication in your browser: "
|
|
275
|
+
f"{Terminal.link(authorize_url, authorize_url)}"
|
|
276
|
+
)
|
|
277
|
+
self._logger.debug(f"Waiting for callback on {redirect_url} ...")
|
|
278
|
+
|
|
279
|
+
# Stop listening every second to unblock
|
|
280
|
+
httpd.timeout = 1
|
|
281
|
+
|
|
282
|
+
# Listen until timeout
|
|
283
|
+
start, elapsed = time.time(), 0
|
|
284
|
+
|
|
285
|
+
while elapsed <= self._timeout:
|
|
286
|
+
httpd.handle_request()
|
|
287
|
+
if httpd.error:
|
|
288
|
+
raise AuthError(httpd.error)
|
|
289
|
+
|
|
290
|
+
if httpd.auth_code:
|
|
291
|
+
self._logger.debug("using code to get tokens...")
|
|
292
|
+
token_response = httpx.post(
|
|
293
|
+
self._tokens_url,
|
|
294
|
+
data={
|
|
295
|
+
"grant_type": "authorization_code",
|
|
296
|
+
"code": httpd.auth_code,
|
|
297
|
+
"redirect_uri": redirect_url,
|
|
298
|
+
"client_id": self._client_id,
|
|
299
|
+
"code_verifier": code_verifier,
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
token_response.raise_for_status()
|
|
305
|
+
except Exception as e:
|
|
306
|
+
try:
|
|
307
|
+
self._logger.debug(
|
|
308
|
+
f"json response: {token_response.json()}"
|
|
309
|
+
)
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
|
|
313
|
+
self._logger.error(e)
|
|
314
|
+
raise AuthError("An error occured when fetching token")
|
|
315
|
+
|
|
316
|
+
tokens: TokenResponseBody = token_response.json()
|
|
317
|
+
self._logger.debug("tokens successfully obtained")
|
|
318
|
+
|
|
319
|
+
self._cache_tokens(tokens)
|
|
320
|
+
token = tokens["access_token"]
|
|
321
|
+
return token
|
|
322
|
+
|
|
323
|
+
elapsed = time.time() - start
|
|
324
|
+
if int(elapsed) % 5 == 0: # log every 5s
|
|
325
|
+
self._logger.debug(f"Still waiting... {int(elapsed)}s elapsed")
|
|
326
|
+
|
|
327
|
+
except AuthError as e:
|
|
328
|
+
self._logger.error(Terminal.color(str(e), "red"))
|
|
329
|
+
raise e
|
|
330
|
+
|
|
331
|
+
except KeyboardInterrupt:
|
|
332
|
+
self._logger.info("get_token interrupted by keyboard")
|
|
333
|
+
raise AuthError("Authentication interrupted by user")
|
|
334
|
+
|
|
335
|
+
if not token or token == "":
|
|
336
|
+
raise AuthError("Failed to get token")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class ClientCredentials(Auth):
|
|
340
|
+
def __init__(
|
|
341
|
+
self,
|
|
342
|
+
client_id: str,
|
|
343
|
+
client_secret: str,
|
|
344
|
+
tokens_url: str = TOKENS_URL,
|
|
345
|
+
timeout: int = 10, # Seconds
|
|
346
|
+
loglevel: str = "INFO",
|
|
347
|
+
) -> None:
|
|
348
|
+
self._client_id = client_id
|
|
349
|
+
self._client_secret = client_secret
|
|
350
|
+
self._tokens_url = tokens_url
|
|
351
|
+
self._timeout = timeout
|
|
352
|
+
|
|
353
|
+
super().__init__(loglevel)
|
|
354
|
+
|
|
355
|
+
def get_token(self) -> str:
|
|
356
|
+
token: str | None = self._load_cached_token()
|
|
357
|
+
if token is not None:
|
|
358
|
+
return token
|
|
359
|
+
|
|
360
|
+
token_response = httpx.post(
|
|
361
|
+
self._tokens_url,
|
|
362
|
+
data={
|
|
363
|
+
"client_id": self._client_id,
|
|
364
|
+
"client_secret": self._client_secret,
|
|
365
|
+
"grant_type": "client_credentials",
|
|
366
|
+
},
|
|
367
|
+
timeout=self._timeout,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
token_response.raise_for_status()
|
|
372
|
+
except Exception as e:
|
|
373
|
+
try:
|
|
374
|
+
self._logger.debug(f"json response: {token_response.json()}")
|
|
375
|
+
except Exception:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
self._logger.error(e)
|
|
379
|
+
raise AuthError("An error occured when fetching token")
|
|
380
|
+
|
|
381
|
+
tokens: TokenResponseBody = token_response.json()
|
|
382
|
+
self._logger.debug("tokens successfully obtained")
|
|
383
|
+
|
|
384
|
+
self._cache_tokens(tokens)
|
|
385
|
+
token = tokens["access_token"]
|
|
386
|
+
return token
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
CLIENT_ID = "nl-public"
|
|
2
|
+
AUTHORIZE_URL = "https://keycloak.test.ngiapi.no/auth/realms/tenant-geohub-public/protocol/openid-connect/auth"
|
|
3
|
+
TOKENS_URL = "https://keycloak.test.ngiapi.no/auth/realms/tenant-geohub-public/protocol/openid-connect/token"
|
|
4
|
+
BASE_URL = "https://api.test.ngilive.no"
|
|
5
|
+
|
|
6
|
+
APP_NAME = "NGILive"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Callable, ParamSpec
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ngilive.log import default_handler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
P = ParamSpec("P")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HTTPXWrapper:
|
|
15
|
+
def __init__(self, loglevel) -> None:
|
|
16
|
+
self.get = self._log_call(httpx.get)
|
|
17
|
+
self.post = self._log_call(httpx.post)
|
|
18
|
+
|
|
19
|
+
self._logger = logging.getLogger("ngilive.httpx")
|
|
20
|
+
self._logger.setLevel(loglevel)
|
|
21
|
+
if not self._logger.handlers:
|
|
22
|
+
self._logger.addHandler(default_handler())
|
|
23
|
+
|
|
24
|
+
def _log_call(self, fn: Callable[P, httpx.Response]) -> Callable[P, httpx.Response]:
|
|
25
|
+
@functools.wraps(fn)
|
|
26
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> httpx.Response:
|
|
27
|
+
url = args[0]
|
|
28
|
+
params = kwargs.get("params", {})
|
|
29
|
+
|
|
30
|
+
_params = (
|
|
31
|
+
" ".join(f"{k}={v}" for k, v in params.items())
|
|
32
|
+
if isinstance(params, dict)
|
|
33
|
+
else ""
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
resp: httpx.Response = fn(*args, **kwargs)
|
|
37
|
+
|
|
38
|
+
status_code = resp.status_code
|
|
39
|
+
|
|
40
|
+
response_body = ""
|
|
41
|
+
if status_code >= 400:
|
|
42
|
+
response_body = " "
|
|
43
|
+
try:
|
|
44
|
+
response_body += json.dumps(resp.json(), ensure_ascii=False)
|
|
45
|
+
except Exception:
|
|
46
|
+
try:
|
|
47
|
+
response_body += resp.text
|
|
48
|
+
except Exception:
|
|
49
|
+
response_body = ""
|
|
50
|
+
|
|
51
|
+
self._logger.error(
|
|
52
|
+
f"{fn.__name__.upper()} {url} {_params} {status_code}{response_body}"
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
self._logger.debug(
|
|
56
|
+
f"{fn.__name__.upper()} {url} {_params} {status_code}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return resp
|
|
60
|
+
|
|
61
|
+
return wrapper
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class Terminal:
|
|
2
|
+
@staticmethod
|
|
3
|
+
def link(text: str, url: str) -> str:
|
|
4
|
+
ESC = "\033"
|
|
5
|
+
START_LINK = f"{ESC}]8;;{url}{ESC}\\"
|
|
6
|
+
END_LINK = f"{ESC}]8;;{ESC}\\"
|
|
7
|
+
STYLE = f"{ESC}[4;34m" # underline + blue
|
|
8
|
+
RESET = f"{ESC}[0m"
|
|
9
|
+
return f"{START_LINK}{STYLE}{text}{RESET}{END_LINK}"
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def color(text: str, color: str = "red") -> str:
|
|
13
|
+
colors = {
|
|
14
|
+
"red": "\033[91m",
|
|
15
|
+
"green": "\033[92m",
|
|
16
|
+
"yellow": "\033[93m",
|
|
17
|
+
"blue": "\033[94m",
|
|
18
|
+
"cyan": "\033[96m",
|
|
19
|
+
"bold": "\033[1m",
|
|
20
|
+
}
|
|
21
|
+
return f"{colors.get(color, '')}{text}\033[0m"
|