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 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
+
@@ -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,4 @@
1
+ from ngilive.api import API
2
+
3
+ __all__ = ["API"]
4
+
@@ -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,13 @@
1
+ import logging
2
+ import sys
3
+
4
+
5
+ def default_handler():
6
+ handler = logging.StreamHandler(sys.stdout)
7
+ handler.setFormatter(
8
+ logging.Formatter(
9
+ "[%(asctime)s] %(name)s %(levelname)s: %(message)s",
10
+ datefmt="%H:%M:%S",
11
+ )
12
+ )
13
+ return handler
@@ -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"