earthscope-sdk 1.0.0b1__py3-none-any.whl → 1.2.0b0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. earthscope_sdk/__init__.py +1 -1
  2. earthscope_sdk/auth/auth_flow.py +8 -6
  3. earthscope_sdk/auth/client_credentials_flow.py +3 -13
  4. earthscope_sdk/auth/device_code_flow.py +19 -10
  5. earthscope_sdk/client/_client.py +47 -0
  6. earthscope_sdk/client/data_access/__init__.py +0 -0
  7. earthscope_sdk/client/data_access/_arrow/__init__.py +0 -0
  8. earthscope_sdk/client/data_access/_arrow/_common.py +94 -0
  9. earthscope_sdk/client/data_access/_arrow/_gnss.py +116 -0
  10. earthscope_sdk/client/data_access/_base.py +85 -0
  11. earthscope_sdk/client/data_access/_query_plan/__init__.py +0 -0
  12. earthscope_sdk/client/data_access/_query_plan/_gnss_observations.py +295 -0
  13. earthscope_sdk/client/data_access/_query_plan/_query_plan.py +259 -0
  14. earthscope_sdk/client/data_access/_query_plan/_request_set.py +133 -0
  15. earthscope_sdk/client/data_access/_service.py +114 -0
  16. earthscope_sdk/client/discovery/__init__.py +0 -0
  17. earthscope_sdk/client/discovery/_base.py +303 -0
  18. earthscope_sdk/client/discovery/_service.py +209 -0
  19. earthscope_sdk/client/discovery/models.py +144 -0
  20. earthscope_sdk/common/context.py +73 -1
  21. earthscope_sdk/common/service.py +10 -8
  22. earthscope_sdk/config/_bootstrap.py +42 -0
  23. earthscope_sdk/config/models.py +54 -21
  24. earthscope_sdk/config/settings.py +11 -0
  25. earthscope_sdk/model/secret.py +29 -0
  26. earthscope_sdk/util/__init__.py +0 -0
  27. earthscope_sdk/util/_concurrency.py +64 -0
  28. earthscope_sdk/util/_itertools.py +57 -0
  29. earthscope_sdk/util/_time.py +57 -0
  30. earthscope_sdk/util/_types.py +5 -0
  31. {earthscope_sdk-1.0.0b1.dist-info → earthscope_sdk-1.2.0b0.dist-info}/METADATA +15 -4
  32. earthscope_sdk-1.2.0b0.dist-info/RECORD +49 -0
  33. {earthscope_sdk-1.0.0b1.dist-info → earthscope_sdk-1.2.0b0.dist-info}/WHEEL +1 -1
  34. earthscope_sdk-1.0.0b1.dist-info/RECORD +0 -28
  35. {earthscope_sdk-1.0.0b1.dist-info → earthscope_sdk-1.2.0b0.dist-info}/licenses/LICENSE +0 -0
  36. {earthscope_sdk-1.0.0b1.dist-info → earthscope_sdk-1.2.0b0.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,4 @@
1
- __version__ = "1.0.0b1"
1
+ __version__ = "1.2.0b0"
2
2
 
3
3
  from earthscope_sdk.client import AsyncEarthScopeClient, EarthScopeClient
4
4
 
@@ -311,9 +311,10 @@ class AuthFlow(httpx.Auth):
311
311
  """
312
312
  super().async_auth_flow
313
313
  if request.headers.get("authorization") is None:
314
- await self.async_refresh_if_necessary()
315
- access_token = self.access_token
316
- request.headers["authorization"] = f"Bearer {access_token}"
314
+ if self._settings.is_host_allowed(request.url.host):
315
+ await self.async_refresh_if_necessary()
316
+ access_token = self.access_token
317
+ request.headers["authorization"] = f"Bearer {access_token}"
317
318
 
318
319
  yield request
319
320
 
@@ -326,8 +327,9 @@ class AuthFlow(httpx.Auth):
326
327
  # NOTE: we explicitly redefine this sync method because ctx.syncify()
327
328
  # does not support generators
328
329
  if request.headers.get("authorization") is None:
329
- self.refresh_if_necessary()
330
- access_token = self.access_token
331
- request.headers["authorization"] = f"Bearer {access_token}"
330
+ if self._settings.is_host_allowed(request.url.host):
331
+ self.refresh_if_necessary()
332
+ access_token = self.access_token
333
+ request.headers["authorization"] = f"Bearer {access_token}"
332
334
 
333
335
  yield request
@@ -1,5 +1,4 @@
1
1
  import logging
2
- from dataclasses import dataclass
3
2
  from json import JSONDecodeError
4
3
 
5
4
  from earthscope_sdk.auth.auth_flow import AuthFlow
@@ -9,12 +8,6 @@ from earthscope_sdk.common.context import SdkContext
9
8
  logger = logging.getLogger(__name__)
10
9
 
11
10
 
12
- @dataclass
13
- class GetTokensErrorResponse:
14
- error: str
15
- error_description: str
16
-
17
-
18
11
  class ClientCredentialsFlow(AuthFlow):
19
12
  """
20
13
  Implements the oauth2 Client Credentials "machine-to-machine" (M2M) flow.
@@ -69,12 +62,9 @@ class ClientCredentialsFlow(AuthFlow):
69
62
 
70
63
  # Unauthorized
71
64
  if r.status_code == 401:
72
- err = GetTokensErrorResponse(**resp)
73
- if err.error == "access_denied":
74
- if err.error_description == "Unauthorized":
75
- raise UnauthorizedError(
76
- f"m2m client '{self._settings.client_id}' is not authorized"
77
- )
65
+ raise UnauthorizedError(
66
+ f"m2m client '{self._settings.client_id}' is not authorized. IdP response: {resp}"
67
+ )
78
68
 
79
69
  # Unhandled
80
70
  raise ClientCredentialsFlowError("client credentials flow failed", r.content)
@@ -1,11 +1,12 @@
1
1
  import logging
2
+ from asyncio import sleep
2
3
  from contextlib import asynccontextmanager, contextmanager
3
- from dataclasses import dataclass
4
4
  from enum import Enum
5
5
  from json import JSONDecodeError
6
- from time import sleep
7
6
  from typing import Optional
8
7
 
8
+ from pydantic import BaseModel, ValidationError
9
+
9
10
  from earthscope_sdk.auth.auth_flow import AuthFlow
10
11
  from earthscope_sdk.auth.error import (
11
12
  DeviceCodePollingError,
@@ -25,18 +26,16 @@ class PollingErrorType(str, Enum):
25
26
  ACCESS_DENIED = "access_denied"
26
27
 
27
28
 
28
- @dataclass
29
- class GetDeviceCodeResponse:
29
+ class GetDeviceCodeResponse(BaseModel):
30
30
  device_code: str
31
31
  user_code: str
32
32
  verification_uri: str
33
33
  verification_uri_complete: str
34
34
  expires_in: int
35
- interval: int
35
+ interval: float
36
36
 
37
37
 
38
- @dataclass
39
- class PollingErrorResponse:
38
+ class PollingErrorResponse(BaseModel):
40
39
  error: PollingErrorType
41
40
  error_description: str
42
41
 
@@ -157,7 +156,7 @@ class DeviceCodeFlow(AuthFlow):
157
156
  try:
158
157
  while True:
159
158
  # IdP-provided poll interval
160
- sleep(codes.interval)
159
+ await sleep(codes.interval)
161
160
 
162
161
  r = await self._ctx.httpx_client.post(
163
162
  f"{self._settings.domain}oauth/token",
@@ -185,7 +184,12 @@ class DeviceCodeFlow(AuthFlow):
185
184
  return self
186
185
 
187
186
  # Keep polling
188
- poll_err = PollingErrorResponse(**resp)
187
+ try:
188
+ poll_err = PollingErrorResponse.model_validate(resp)
189
+ except ValidationError as e:
190
+ raise DeviceCodePollingError(
191
+ f"Failed to unpack polling response: {r.text}"
192
+ ) from e
189
193
  if poll_err.error in [
190
194
  PollingErrorType.AUTHORIZATION_PENDING,
191
195
  PollingErrorType.SLOW_DOWN,
@@ -235,7 +239,12 @@ class DeviceCodeFlow(AuthFlow):
235
239
  f"Failed to get a device code: {r.content}"
236
240
  )
237
241
 
238
- codes = GetDeviceCodeResponse(**r.json())
242
+ try:
243
+ codes = GetDeviceCodeResponse.model_validate_json(r.content)
244
+ except ValidationError as e:
245
+ raise DeviceCodeRequestDeviceCodeError(
246
+ f"Failed to unpack device code response: {r.text}"
247
+ ) from e
239
248
 
240
249
  logger.debug(f"Got device code response: {codes}")
241
250
  return codes
@@ -8,6 +8,26 @@ class AsyncEarthScopeClient(AsyncSdkClient):
8
8
  An async client for interacting with api.earthscope.org
9
9
  """
10
10
 
11
+ @cached_property
12
+ def data(self):
13
+ """
14
+ Data access functionality
15
+ """
16
+ # lazy load
17
+ from earthscope_sdk.client.data_access._service import AsyncDataAccessService
18
+
19
+ return AsyncDataAccessService(self)
20
+
21
+ @cached_property
22
+ def discover(self):
23
+ """
24
+ Data discovery functionality
25
+ """
26
+ # lazy load
27
+ from earthscope_sdk.client.discovery._service import AsyncDiscoveryService
28
+
29
+ return AsyncDiscoveryService(self._ctx)
30
+
11
31
  @cached_property
12
32
  def user(self):
13
33
  """
@@ -24,6 +44,33 @@ class EarthScopeClient(SdkClient):
24
44
  A client for interacting with api.earthscope.org
25
45
  """
26
46
 
47
+ @cached_property
48
+ def _async_client(self):
49
+ """
50
+ An async client for interacting with api.earthscope.org
51
+ """
52
+ return AsyncEarthScopeClient(ctx=self._ctx)
53
+
54
+ @cached_property
55
+ def data(self):
56
+ """
57
+ Data access functionality
58
+ """
59
+ # lazy load
60
+ from earthscope_sdk.client.data_access._service import DataAccessService
61
+
62
+ return DataAccessService(self._async_client)
63
+
64
+ @cached_property
65
+ def discover(self):
66
+ """
67
+ Data discovery functionality
68
+ """
69
+ # lazy load
70
+ from earthscope_sdk.client.discovery._service import DiscoveryService
71
+
72
+ return DiscoveryService(self._ctx)
73
+
27
74
  @cached_property
28
75
  def user(self):
29
76
  """
File without changes
File without changes
@@ -0,0 +1,94 @@
1
+ try:
2
+ import pyarrow as pa
3
+ except ModuleNotFoundError as e:
4
+ raise ModuleNotFoundError(
5
+ "Optional dependency 'pyarrow' is required for this feature. "
6
+ "Install it with: pip install earthscope-sdk[arrow]"
7
+ ) from e
8
+
9
+
10
+ from earthscope_sdk.client.discovery.models import DatasourceBaseModel
11
+
12
+
13
+ def convert_time(
14
+ table: pa.Table,
15
+ *,
16
+ source_field: str = "time",
17
+ target_field: str = "timestamp",
18
+ unit: str = "ms",
19
+ tz: str = "UTC",
20
+ ) -> pa.Table:
21
+ """
22
+ Convert a unix timestamp (integer since epoch) field to a timestamp type.
23
+
24
+ Args:
25
+ table: The table to convert.
26
+ source_field: The name of the field to convert.
27
+ target_field: The name of the field to store the result.
28
+ unit: The unit of the time field.
29
+ tz: The timezone of the time field.
30
+
31
+ Returns:
32
+ The converted table.
33
+ """
34
+ if unit not in {"s", "ms", "us", "ns"}:
35
+ raise ValueError(f"Invalid time unit: {unit}")
36
+
37
+ time_idx = table.schema.get_field_index(source_field)
38
+ if time_idx < 0:
39
+ return table
40
+
41
+ return table.set_column(
42
+ time_idx,
43
+ target_field,
44
+ table[source_field].cast(pa.timestamp(unit, tz)),
45
+ )
46
+
47
+
48
+ def get_datasource_metadata_table(
49
+ datasources: list[DatasourceBaseModel],
50
+ *,
51
+ fields: list[str],
52
+ namespaces: list[str],
53
+ ) -> pa.Table:
54
+ """
55
+ Get a pyarrow table containing metadata columns for a list of datasources.
56
+
57
+ Args:
58
+ datasources: The list of datasources to get metadata for.
59
+ fields: The fields to include in the metadata table.
60
+ namespaces: The namespaces to include in the metadata table.
61
+
62
+ Returns:
63
+ The metadata table.
64
+ """
65
+ return pa.Table.from_pylist(
66
+ [s.to_arrow_columns(fields=fields, namespaces=namespaces) for s in datasources]
67
+ )
68
+
69
+
70
+ def load_table_with_extra(content: bytes, *, is_stream=True, **kwargs) -> pa.Table:
71
+ """
72
+ Load a table from an arrow stream or file. Optionally add extra metadata columns.
73
+
74
+ Args:
75
+ content: The content of the stream or file.
76
+ is_stream: Whether the content is an arrow stream or an arrow file.
77
+ **kwargs: The extra metadata columns to add to the table.
78
+
79
+ Returns:
80
+ The loaded table.
81
+ """
82
+ if is_stream:
83
+ reader = pa.ipc.open_stream(content)
84
+ else:
85
+ reader = pa.ipc.open_file(content)
86
+
87
+ table = reader.read_all()
88
+
89
+ if kwargs:
90
+ count = len(table)
91
+ for key, value in kwargs.items():
92
+ table = table.append_column(key, pa.repeat(value, count))
93
+
94
+ return table
@@ -0,0 +1,116 @@
1
+ import pyarrow as pa
2
+ import pyarrow.compute as pc
3
+
4
+ from earthscope_sdk.client.data_access._arrow._common import convert_time
5
+
6
+ _SYSTEM_ID = "system_id"
7
+ _SYSTEM_TABLE = pa.Table.from_pylist(
8
+ [
9
+ {_SYSTEM_ID: 1, "system": "G"}, # GPS
10
+ {_SYSTEM_ID: 2, "system": "R"}, # GLONASS
11
+ {_SYSTEM_ID: 3, "system": "S"}, # SBAS
12
+ {_SYSTEM_ID: 4, "system": "E"}, # Galileo
13
+ {_SYSTEM_ID: 5, "system": "C"}, # BeiDou
14
+ {_SYSTEM_ID: 6, "system": "J"}, # QZSS
15
+ {_SYSTEM_ID: 7, "system": "I"}, # IRNSS/NavIC
16
+ ],
17
+ schema=pa.schema(
18
+ [
19
+ pa.field(_SYSTEM_ID, pa.uint8()),
20
+ pa.field("system", pa.string()),
21
+ ]
22
+ ),
23
+ )
24
+
25
+
26
+ def _convert_obs_code(
27
+ table: pa.Table,
28
+ *,
29
+ source_field: str = "obs",
30
+ target_field: str = "obs_code",
31
+ ) -> pa.Table:
32
+ """
33
+ Convert the obs_code field from packed chars to a string.
34
+
35
+ Args:
36
+ table: The table to convert.
37
+ source_field: The name of the field to convert.
38
+ target_field: The name of the field to store the result.
39
+
40
+ Returns:
41
+ The converted table.
42
+ """
43
+ # extract obs_code (encoded as packed chars)
44
+ result_chunks = [
45
+ pc.cast(
46
+ pa.FixedSizeBinaryArray.from_buffers(
47
+ type=pa.binary(2),
48
+ length=len(chunk),
49
+ buffers=[None, chunk.buffers()[1]],
50
+ ),
51
+ pa.string(),
52
+ )
53
+ for chunk in table[source_field].chunks
54
+ ]
55
+
56
+ return table.set_column(
57
+ table.schema.get_field_index(source_field),
58
+ target_field,
59
+ # can't easily swap endianness of packed chars, so reverse the string
60
+ pc.utf8_reverse(pa.chunked_array(result_chunks, type=pa.string())),
61
+ )
62
+
63
+
64
+ def _convert_system(table: pa.Table, *, field: str) -> pa.Table:
65
+ """
66
+ Convert the sys field from integer to string.
67
+
68
+ Args:
69
+ table: The table to convert.
70
+ field: The name of the field to convert.
71
+
72
+ Returns:
73
+ The converted table.
74
+ """
75
+ return table.join(_SYSTEM_TABLE, field, right_keys=_SYSTEM_ID).drop(field)
76
+
77
+
78
+ def prettify_observations_table(table: pa.Table) -> pa.Table:
79
+ """
80
+ Clean up a raw observations table (as stored in TileDB) for easier use.
81
+
82
+ Args:
83
+ table: The table to prettify.
84
+
85
+ Returns:
86
+ The prettified table.
87
+ """
88
+ # Cast time column (integer milliseconds since epoch) to timestamp type
89
+ table = convert_time(table)
90
+
91
+ # Expose friendlier column names
92
+ table = table.rename_columns({"sat": "satellite"})
93
+
94
+ # Convert system column from integer to string
95
+ table = _convert_system(table, field="sys")
96
+
97
+ # Convert obs_code column from packed chars to string
98
+ table = _convert_obs_code(table)
99
+
100
+ return table
101
+
102
+
103
+ def prettify_positions_table(table: pa.Table) -> pa.Table:
104
+ """
105
+ Clean up a raw positions table (as stored in TileDB) for easier use.
106
+
107
+ Args:
108
+ table: The table to prettify.
109
+
110
+ Returns:
111
+ The prettified table.
112
+ """
113
+ # Convert time column (integer milliseconds since epoch) to timestamp type
114
+ table = convert_time(table)
115
+
116
+ return table
@@ -0,0 +1,85 @@
1
+ import datetime as dt
2
+ from typing import TYPE_CHECKING, Union
3
+
4
+ from earthscope_sdk.client.data_access._arrow._common import load_table_with_extra
5
+ from earthscope_sdk.common.service import SdkService
6
+ from earthscope_sdk.util._time import to_utc_dt
7
+
8
+ if TYPE_CHECKING:
9
+ from pyarrow import Table
10
+
11
+
12
+ class DataAccessBaseService(SdkService):
13
+ """
14
+ L1 data access service functionality
15
+ """
16
+
17
+ async def _get_gnss_observations(
18
+ self,
19
+ *,
20
+ session_edid: str,
21
+ start_datetime: dt.datetime,
22
+ end_datetime: dt.datetime,
23
+ system: Union[str, list[str]] = [],
24
+ satellite: Union[str, list[str]] = [],
25
+ obs_code: Union[str, list[str]] = [],
26
+ field: Union[str, list[str]] = [],
27
+ ) -> "Table":
28
+ """
29
+ Retrieve GNSS observations.
30
+
31
+ This method retrieves GNSS observations for a given session.
32
+
33
+ Args:
34
+ session_edid: The session EDID to retrieve observations for.
35
+ start_datetime: The start datetime to retrieve positions for.
36
+ end_datetime: The end datetime to retrieve positions for.
37
+ system: The system(s) to retrieve observations for.
38
+ satellite: The satellite(s) to retrieve observations for.
39
+ obs_code: The observation code(s) to retrieve observations for.
40
+ field: The field(s) to retrieve observations for.
41
+
42
+ Returns:
43
+ A pyarrow table containing the GNSS observations.
44
+ """
45
+
46
+ # lazy import
47
+
48
+ headers = {
49
+ "accept": "application/vnd.apache.arrow.stream",
50
+ }
51
+
52
+ params = {
53
+ "session_id": session_edid,
54
+ "start_datetime": to_utc_dt(start_datetime).isoformat(),
55
+ "end_datetime": to_utc_dt(end_datetime).isoformat(),
56
+ }
57
+
58
+ if system:
59
+ params["system"] = system
60
+
61
+ if satellite:
62
+ params["satellite"] = satellite
63
+
64
+ if obs_code:
65
+ params["obs_code"] = obs_code
66
+
67
+ if field:
68
+ params["field"] = field
69
+
70
+ req = self.ctx.httpx_client.build_request(
71
+ method="GET",
72
+ url=f"{self.resources.api_url}beta/data-products/gnss/observations",
73
+ headers=headers,
74
+ params=params,
75
+ timeout=30,
76
+ )
77
+
78
+ resp = await self._send_with_retries(req)
79
+
80
+ return await self.ctx.run_in_executor(
81
+ load_table_with_extra,
82
+ resp.content,
83
+ # Add edid column to the table returned by the API
84
+ edid=session_edid,
85
+ )