maxar-platform 1.0.0__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.
@@ -0,0 +1,32 @@
1
+ """
2
+ Provides
3
+ --------
4
+
5
+ Streamlined access to these Maxar Geospatial Platform APIs:
6
+
7
+ - Authentication
8
+ - Discovery Catalog
9
+ - OGC Image streaming, vectors, and metadata
10
+ - Ordering
11
+ - Monitoring
12
+ - Tasking
13
+ - Raster Analytics
14
+
15
+ Coming soon:
16
+
17
+ - 3D Analytics
18
+ - Change Monitoring
19
+
20
+ Reference
21
+ ---------
22
+
23
+ https://developers.maxar.com/
24
+ """
25
+
26
+ __version__ = "1.0.0"
27
+
28
+ import os
29
+
30
+ BASE_URL = os.environ.get("MGP_BASE_URL") or "https://api.maxar.com/"
31
+ CLIENT_ID = os.environ.get("MAXAR_CLIENT_ID") or "mgp"
32
+ PACKAGE = "maxar_platform"
@@ -0,0 +1,179 @@
1
+ """
2
+ API keys are an alternative to OAuth2 authentication.
3
+
4
+ They are more compact than OAuth2 bearer tokens which makes them easier to send as URL parameters.
5
+
6
+ Unlike bearer tokens which expire in two hours, API keys have a default 6 month lifespan. It is also possible to
7
+ set the expiration date as short as needed. API keys can not be renewed or extended, once they expire a new one must
8
+ be created.
9
+
10
+ API keys have the same roles and scopes as your account but can not be used on any of the administrative endpoints:
11
+
12
+ - Admin API
13
+ - API Key Service
14
+ - Oauth2 Authentication
15
+
16
+ API Key dictionaries use a custom representations that mask out the API key string when they are displayed/printed.
17
+
18
+ >>> key = generate_key("my key")
19
+ >>> print(key)
20
+
21
+ {'id': '5431dd15-3efe-418a-b04d-acdca3a4d281',
22
+ 'name': 'my key',
23
+ 'description': '',
24
+ 'api_key': '*******',
25
+ 'last_modified_date': '2023-07-20 19:44:07',
26
+ 'created_date': '2023-07-20 19:44:07',
27
+ 'expiration_date': '2024-03-12 18:33:23'}
28
+
29
+ The `api_key` value is not otherwise masked, be careful with:
30
+
31
+ >>> print(key['api_key'])
32
+ <your key will print out!>
33
+
34
+
35
+ Reference
36
+ ---------
37
+
38
+ https://developers.maxar.com/docs/authentication/guides/api-key
39
+
40
+ """
41
+
42
+ import pprint
43
+ from posixpath import join
44
+
45
+ import pendulum
46
+
47
+ from maxar_platform import BASE_URL
48
+ from maxar_platform.exceptions import MissingAPIResource
49
+ from maxar_platform.session import session
50
+
51
+ __all__ = ["generate_key", "list_keys", "delete_key", "get_key"]
52
+
53
+
54
+ KEYS_URL = join(BASE_URL, "api-key/api/v2/api-key")
55
+ KEYS_KEY = join(KEYS_URL, "id/:id")
56
+
57
+
58
+ def generate_key(name="", description="", expiration=None, lifespan=None):
59
+ """Generate a new API key
60
+
61
+ Keywords
62
+ ----------
63
+ name: str, optional
64
+ Name of key, while optional highly recommended.
65
+ description: str, optional
66
+ Description of key.
67
+ expiration: datelike object
68
+ Expiration date. Can be Date or Datetime objects, or strings of dates in common formats. Maximum lifespan is 6 months.
69
+ lifespan: int
70
+ Lifespan of keys in days if you don't want to compute an expiration date.
71
+
72
+ Returns
73
+ -------
74
+ KeyDict
75
+ Key as Python dictionary with special `str` and `repr` methods to mask keys from accidental display.
76
+
77
+ """
78
+
79
+ expires_format = "YYYY-MM-DD HH:mm:ss"
80
+
81
+ payload = {"name": name, "description": description}
82
+
83
+ if expiration and lifespan:
84
+ raise ValueError("Tokens can be given an `expiration` OR a `lifespan`, not both")
85
+
86
+ if expiration:
87
+ expires = pendulum.parse(expiration)
88
+ payload["expiration_date"] = expires.format(expires_format)
89
+
90
+ if lifespan:
91
+ expires = pendulum.now().add(days=lifespan)
92
+ payload["expiration_date"] = expires.format(expires_format)
93
+
94
+ return KeyDict(session.oauth_session.post(KEYS_URL, json=payload).json())
95
+
96
+
97
+ def list_keys():
98
+ """List keys associated with your user
99
+
100
+ Returns
101
+ -------
102
+ list of KeyDict
103
+ All of user's keys, including expired ones
104
+
105
+ """
106
+ return [KeyDict(k) for k in session.oauth_session.get(KEYS_URL).json()]
107
+
108
+
109
+ def delete_key(key_or_id):
110
+ """Delete an API key
111
+
112
+ Parameters
113
+ ----------
114
+ key: KeyDict or str
115
+ Either a key dictionary, or a key's ID string
116
+
117
+ Returns
118
+ -------
119
+ None
120
+
121
+ Note
122
+ ----
123
+ A key's ID is the 'id' value, not the 'api_key' value that is used for authentication.
124
+
125
+
126
+ """
127
+ if isinstance(key_or_id, dict):
128
+ key_id = key_or_id["id"]
129
+ else:
130
+ key_id = key_or_id
131
+ url = KEYS_KEY.replace(":id", key_id)
132
+ try:
133
+ session.oauth_session.delete(url)
134
+ except MissingAPIResource:
135
+ raise MissingAPIResource(f"API Key with ID {key_id} was not found")
136
+
137
+
138
+ def get_key(**kwargs):
139
+ """Get a key
140
+
141
+ Keywords
142
+ ----------
143
+ id: str, optional
144
+ name: str, optional
145
+ description: str, optional
146
+ api_key: str, optional
147
+
148
+ Returns
149
+ -------
150
+ KeyDict
151
+ The first key found matching the supplied keyword arguments.
152
+
153
+ Note
154
+ ----
155
+ You can match to any ONE of the KeyDict fields, however in most cases you would use ID or name.
156
+ Matching is exact, there is no support for wildcards or other regex magic at this time.
157
+
158
+ """
159
+ if len(kwargs) > 1:
160
+ raise ValueError("API keys can only be retrieved on a single property")
161
+ for k, v in kwargs.items():
162
+ for key in list_keys():
163
+ if key[k] == v:
164
+ return KeyDict(key)
165
+ raise MissingAPIResource(f"API Key with {k} `{v}` was not found")
166
+
167
+
168
+ class KeyDict(dict):
169
+ """A dictionary class for keys that masks the key value when printed or displayed"""
170
+
171
+ def __str__(self):
172
+ masked = self.copy()
173
+ masked["api_key"] = "**************"
174
+ return pprint.pformat(masked, sort_dicts=False)
175
+
176
+ def __repr__(self):
177
+ masked = self.copy()
178
+ masked["api_key"] = "*******"
179
+ return str(masked)
@@ -0,0 +1,163 @@
1
+ """ Discovery API
2
+
3
+ Discovery is Maxar's data catalog. Since it implements STAC API, this SDK uses `pystac_client` for access.
4
+
5
+ For search, open a catalog or subcatalog to get a STAC client.
6
+
7
+
8
+ from maxar_platform.discovery import open_catalog
9
+ open_catalog() # returns the root catalog, or pass a subcatalog name like "imagery"
10
+ results = stac_api_client.search(...)
11
+
12
+
13
+ Reference
14
+ ---------
15
+ https://developers.maxar.com/docs/discovery/
16
+
17
+ https://pystac-client.readthedocs.io/en/latest/usage.html#itemsearch
18
+
19
+ """
20
+
21
+ import json
22
+ import logging
23
+ from copy import deepcopy
24
+ from posixpath import join
25
+
26
+ from pygeofilter.backends.cql2_json import to_cql2
27
+ from pygeofilter.parsers.cql2_text import parse as cql2_parse
28
+ from pystac_client import Client
29
+ from pystac_client.exceptions import APIError
30
+ from pystac_client.stac_api_io import StacApiIO
31
+
32
+ from maxar_platform import BASE_URL
33
+ from maxar_platform.session import session
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ from typing import Any, Dict, Optional
38
+
39
+ DISCOVERY_URL = join(BASE_URL, "discovery/v1/")
40
+ DISCOVERY_CATALOG = join(DISCOVERY_URL, "catalogs/:subCatalogId")
41
+
42
+ __all__ = ["stac_api_client", "open_catalog"]
43
+
44
+
45
+ class MaxarStacApiIO(StacApiIO):
46
+
47
+ area_based_calc = False
48
+
49
+ def request(
50
+ self,
51
+ href: str,
52
+ method: Optional[str] = None,
53
+ headers: Optional[Dict[str, str]] = None,
54
+ parameters: Optional[Dict[str, Any]] = None,
55
+ ) -> str:
56
+ """Makes a request to an http endpoint
57
+
58
+ Args:
59
+ href (str): The request URL
60
+ method (Optional[str], optional): The http method to use, 'GET' or 'POST'.
61
+ Defaults to None, which will result in 'GET' being used.
62
+ headers (Optional[Dict[str, str]], optional): Additional headers to include
63
+ in request. Defaults to None.
64
+ parameters (Optional[Dict[str, Any]], optional): parameters to send with
65
+ request. Defaults to None.
66
+
67
+ Raises:
68
+ APIError: raised if the server returns an error response
69
+
70
+ Return:
71
+ str: The decoded response from the endpoint
72
+
73
+ Note:
74
+ This follows the default StacApiIO call but uses the SDK's session object. It also converts
75
+ cql2 text queries to json since the Discovery API currently doesn't accept text queries in
76
+ POST queries.
77
+ """
78
+ if method == "POST":
79
+ if parameters.get("filter-lang") == "cql2-text":
80
+ ast = cql2_parse(parameters["filter"])
81
+ parameters["filter"] = json.loads(to_cql2(ast))
82
+ parameters["filter-lang"] = "cql2-json"
83
+ if self.area_based_calc and parameters:
84
+ if "bbox" in parameters or "intersects" in parameters:
85
+ parameters.update({"area-based-calc": True})
86
+ resp = session.post(url=href, headers=headers, json=parameters)
87
+ else:
88
+ params = deepcopy(parameters) or {}
89
+ if self.area_based_calc and parameters:
90
+ if "bbox" in parameters or "intersects" in parameters:
91
+ params.update({"area-based-calc": 1})
92
+ resp = session.get(url=href, headers=headers, params=params)
93
+ try:
94
+ return resp.content.decode("utf-8")
95
+ except Exception as err:
96
+ raise APIError(str(err))
97
+
98
+
99
+ def open_catalog(catalog=None):
100
+ """Open a pystac_client connection to a Discovery catalog
101
+
102
+ Parameters
103
+ ----------
104
+ catalog : str, default None
105
+ Catalog or subcatalog name to open, i.e. "imagery". If omitted the root catalog is used.
106
+
107
+ Returns
108
+ -------
109
+ pystac_client.Client
110
+ A Client object, configured to use Maxar authentication and area-based calculations
111
+
112
+ Reference
113
+ ---------
114
+ https://pystac-client.readthedocs.io/en/latest/api.html#collection-client"""
115
+
116
+ if not catalog:
117
+ url = DISCOVERY_URL
118
+ else:
119
+ url = DISCOVERY_CATALOG.replace(":subCatalogId", catalog)
120
+
121
+ client = Client.open(url, stac_io=MaxarStacApiIO())
122
+
123
+ def abc_getter(self):
124
+ return self._stac_io.area_based_calc
125
+
126
+ def abc_setter(self, val):
127
+ if type(val) is not bool:
128
+ raise ValueError("Value must be a boolean")
129
+ self._stac_io.area_based_calc = val
130
+
131
+ setattr(
132
+ client.__class__,
133
+ "area_based_calc",
134
+ property(abc_getter, abc_setter, doc="Return metadata with area-based calculations"),
135
+ )
136
+
137
+ return client
138
+
139
+
140
+ stac_api_client = open_catalog()
141
+ """ A pre-configured `pystac-client` instance ready to search the root catalog.
142
+
143
+
144
+ Attributes
145
+ ----------
146
+ area_based_calc : Bool
147
+ Toggles area-based calculations, default is False
148
+
149
+ Reference
150
+ ---------
151
+ https://pystac-client.readthedocs.io/en/latest/api.html#collection-client """
152
+
153
+
154
+ # This is handy for certain searches
155
+ CATID_TO_COLLECTION = {
156
+ "1010": "qb01",
157
+ "1020": "wv01",
158
+ "1030": "wv02",
159
+ "1040": "wv03-vnir",
160
+ "104A": "wv03-swir",
161
+ "1050": "ge01",
162
+ "1060": "ik02",
163
+ }
@@ -0,0 +1,124 @@
1
+ """ SDK Exceptions """
2
+
3
+ from datetime import datetime
4
+
5
+ __all__ = [
6
+ "MissingDependency",
7
+ "APIServerException",
8
+ "BadAPIRequest",
9
+ "MissingAPIResource",
10
+ "OversizeRequestException",
11
+ "UnAuthorizedException",
12
+ ]
13
+
14
+ #########
15
+ # General Errors
16
+ #########
17
+
18
+
19
+ class MissingDependency(Exception):
20
+ """A dependency required for this functionality has not been installed"""
21
+
22
+ pass
23
+
24
+
25
+ #########
26
+ # API Errors
27
+ #########
28
+
29
+
30
+ class APIServerException(Exception):
31
+ """The API service encountered an error"""
32
+
33
+ def __init__(self, msg, *args, **kwargs):
34
+ msg = f"{msg} ({datetime.now()})"
35
+ super().__init__(msg, *args, **kwargs)
36
+
37
+
38
+ class BadAPIRequest(Exception):
39
+ """The API request sent was incorrectly formatted"""
40
+
41
+ pass
42
+
43
+
44
+ class MissingAPIResource(Exception):
45
+ """An API request returned a 404"""
46
+
47
+ pass
48
+
49
+
50
+ class OversizeRequestException(Exception):
51
+ """The API request sent was incorrectly formatted"""
52
+
53
+ pass
54
+
55
+
56
+ class UnAuthorizedException(Exception):
57
+ """The API request's credentials were rejected"""
58
+
59
+ pass
60
+
61
+
62
+ ########
63
+ # Hook functions for Request.Session.hooks
64
+ ########
65
+
66
+
67
+ def messenger(request):
68
+ try:
69
+ payload = request.json()
70
+ message = payload.get("message")
71
+ messages = payload.get("error").get("error_messages")
72
+ if messages:
73
+ messages = "; ".join(messages)
74
+ return message or messages or request.text
75
+ except:
76
+ return request.text
77
+
78
+
79
+ def oversize_request_hook(r, *args, **kwargs):
80
+ """
81
+ Hook for handling oversize request errors from API.
82
+ """
83
+ if r.status_code == 413 and "Request Entity Too Large" in r.json()["message"]:
84
+ raise OversizeRequestException(f"{messenger(r)}, Reduce size of attributes and try again.")
85
+
86
+ return r
87
+
88
+
89
+ def bad_api_request_hook(r, *args, **kwargs):
90
+ """
91
+ Hook for handling 400 errors from API.
92
+ """
93
+ if r.status_code == 400:
94
+ raise BadAPIRequest(messenger(r))
95
+ return r
96
+
97
+
98
+ def unauth_request_hook(r, *args, **kwargs):
99
+ """
100
+ Hook for handling 401 errors from API.
101
+ """
102
+ if r.status_code in [401, 403]:
103
+ raise UnAuthorizedException(
104
+ f"Server responded with {r.status_code} '{messenger(r)}', check your credentials and try again."
105
+ )
106
+
107
+ return r
108
+
109
+
110
+ def missing_resource_hook(r, *args, **kwargs):
111
+ if r.status_code == 404:
112
+ raise MissingAPIResource(messenger(r))
113
+
114
+ return r
115
+
116
+
117
+ def api_server_request_hook(r, *args, **kwargs):
118
+ """
119
+ Hook for handling non-20X errors from API.
120
+ """
121
+ if not r.ok:
122
+ raise APIServerException(messenger(r))
123
+
124
+ return r
@@ -0,0 +1,171 @@
1
+ """
2
+ Provides
3
+ --------
4
+
5
+ Functions to maintain Monitors:
6
+
7
+ - create and update a monitor
8
+ - enable/disable a monitor
9
+ - get one or list many monitors
10
+ - see events that the monitor has matched
11
+
12
+ Reference
13
+ ---------
14
+ https://developers.maxar.com/docs/monitoring/
15
+ """
16
+
17
+ from posixpath import join
18
+ from typing import List, Union
19
+
20
+ from maxar_platform import BASE_URL
21
+ from maxar_platform.session import paginated_request, session
22
+
23
+ __all__ = (
24
+ "create_monitor",
25
+ "get_monitor",
26
+ "disable_monitor",
27
+ "enable_monitor",
28
+ "update_monitor",
29
+ "list_monitors",
30
+ "list_monitor_events",
31
+ )
32
+
33
+ MONITORING_BASE = BASE_URL + "monitoring/v1/monitors"
34
+ MONITORING_MONITOR = join(MONITORING_BASE, ":id")
35
+ MONITORING_EVENTS = join(MONITORING_MONITOR, "events")
36
+ MONITORING_ENABLE = join(MONITORING_MONITOR, "enable")
37
+ MONITORING_DISABLE = join(MONITORING_MONITOR, "disable")
38
+
39
+
40
+ def list_monitors(
41
+ limit=100,
42
+ sort="desc",
43
+ filter=None,
44
+ ):
45
+ """Fetch user's monitors
46
+
47
+ Parameters
48
+ ----------
49
+ limit: int or None, default None
50
+ Maximum number of orders to fetch, None means unlimited, default is 100.
51
+ sort: str
52
+ Sort order for order IDs, either 'asc' (ascending) or 'desc' (descending). Default is 'desc'.
53
+ filter: str or iterable of strings
54
+ Filter results using `field:value` syntax.
55
+ Matches are case sensitive and will match if the value is found anywhere in the field: filtering on a value of `description:NV` will return `Las Vegas, NV` along with `DENVER CO` descriptions.
56
+ Multiple filters can be passed but keys must be unique since filters will be logically ANDed.
57
+ Example: 'description:denver' or ['name:denver', 'description:parking']
58
+
59
+ Returns
60
+ -------
61
+ list
62
+ monitor objects matching parameters"""
63
+
64
+ params = {}
65
+
66
+ if filter is not None:
67
+ params["filter"] = filter
68
+
69
+ return paginated_request("GET", MONITORING_BASE, "monitors", sort, limit, **params)
70
+
71
+
72
+ def get_monitor(monitor_id):
73
+ """Fetch raw data about a monitor from an ID
74
+
75
+ Parameters
76
+ ----------
77
+ monitor_id : str
78
+ Monitor ID to fetch metadata for
79
+
80
+ Returns
81
+ -------
82
+ dict
83
+ API data for the given monitor"""
84
+
85
+ url = MONITORING_MONITOR.replace(":id", monitor_id)
86
+ r = session.get(url)
87
+ return r.json()["data"]
88
+
89
+
90
+ def create_monitor(monitor_payload):
91
+ """Create a new monitor
92
+
93
+ Parameters
94
+ ----------
95
+ monitor_payload : dict
96
+ Dictionary of monitor settings
97
+
98
+ Returns
99
+ -------
100
+ dict
101
+ Data for the monitor"""
102
+
103
+ r = session.post(MONITORING_BASE, json=monitor_payload)
104
+ return r.json()["data"]
105
+
106
+
107
+ def update_monitor(monitor_id, monitor_payload):
108
+ """Update an existing monitor with new settings
109
+
110
+ Parameters
111
+ ----------
112
+ monitor_id : str
113
+ Monitor ID to fetch metadata for
114
+ monitor_payload : dict
115
+ Dictionary of updated fields for the existing monitor
116
+
117
+ Returns
118
+ -------
119
+ dict
120
+ Data for the updated monitor"""
121
+
122
+ url = MONITORING_MONITOR.replace(":id", monitor_id)
123
+ r = session.patch(url, json=monitor_payload)
124
+ return r.json()["data"]
125
+
126
+
127
+ def list_monitor_events(monitor_id):
128
+ """Fetch the events for a monitor from an ID
129
+
130
+ Parameters
131
+ ----------
132
+ monitor_id : str
133
+ monitor ID to fetch events for
134
+
135
+ Returns
136
+ -------
137
+ list
138
+ Matched events for the given monitor"""
139
+
140
+ url = MONITORING_EVENTS.replace(":id", monitor_id)
141
+ return paginated_request("GET", url, "events", sort="desc")
142
+
143
+
144
+ def disable_monitor(monitor_id):
145
+ """Disable a monitor
146
+
147
+ Parameters
148
+ ----------
149
+ monitor_id : str
150
+ monitor ID to disable
151
+
152
+ Returns
153
+ -------"""
154
+
155
+ url = MONITORING_DISABLE.replace(":id", monitor_id)
156
+ r = session.post(url)
157
+
158
+
159
+ def enable_monitor(monitor_id):
160
+ """Enable a monitor
161
+
162
+ Parameters
163
+ ----------
164
+ monitor_id : str
165
+ monitor ID to enable
166
+
167
+ Returns
168
+ -------"""
169
+
170
+ url = MONITORING_ENABLE.replace(":id", monitor_id)
171
+ r = session.post(url)