arcticsecurity 0.1.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.
File without changes
File without changes
@@ -0,0 +1,4 @@
1
+ # ruff: noqa: F401 F403
2
+
3
+ from .errors import *
4
+ from .sharing_api import Query, Sync, query
@@ -0,0 +1,376 @@
1
+ """
2
+ Sharing API client.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ import time
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+ from types import TracebackType
12
+ from typing import Any, Optional, Union
13
+ from urllib.parse import parse_qs, urlparse, urlunparse
14
+
15
+ import httpx
16
+
17
+ from . import _version
18
+ from .errors import (
19
+ ConfigError,
20
+ Error,
21
+ InvalidTokenError,
22
+ NetworkError,
23
+ Retry,
24
+ ServerError,
25
+ TimeoutError,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class Timeout:
32
+ def __init__(self, timeout: Optional[float]):
33
+ self._max_duration = timeout or 0
34
+ self._start_ts = time.monotonic()
35
+
36
+ def start(self) -> None:
37
+ self._start_ts = time.monotonic()
38
+
39
+ def stop(self) -> None:
40
+ pass
41
+
42
+ def check(self) -> None:
43
+ if 0 < self._max_duration < time.monotonic() - self._start_ts:
44
+ raise TimeoutError("Query timed out")
45
+
46
+
47
+ @dataclass
48
+ class Query:
49
+ """Handle one 3-phase async query.
50
+
51
+ Check for timeout every time client is accessed.
52
+ """
53
+
54
+ _client: httpx.Client
55
+ timeout: Timeout
56
+
57
+ # Query details for introspection
58
+ post_url: Optional[httpx.URL] = None
59
+
60
+ def __enter__(self) -> "Query":
61
+ self._client.__enter__()
62
+ self.timeout.start()
63
+ return self
64
+
65
+ def __exit__(
66
+ self,
67
+ exc_type: Optional[type[BaseException]],
68
+ exc: Optional[BaseException],
69
+ tb: Optional[TracebackType],
70
+ ) -> Any:
71
+ self.timeout.stop()
72
+ return self._client.__exit__(exc_type, exc, tb)
73
+
74
+ @property
75
+ def client(self) -> httpx.Client:
76
+ self.timeout.check()
77
+ return self._client
78
+
79
+
80
+ class _ApiClient:
81
+ """Sharing API client."""
82
+
83
+ # Allowed query parameters
84
+ allowed_params = {
85
+ "filter",
86
+ "projection",
87
+ "limit",
88
+ "token",
89
+ "start",
90
+ "end",
91
+ "sort",
92
+ "reverse",
93
+ }
94
+
95
+ def __init__(
96
+ self,
97
+ url: str,
98
+ *,
99
+ user_agent: Optional[str] = None,
100
+ transport: Optional[httpx.BaseTransport] = None,
101
+ sleep_before_first_status_query: float = 0.5,
102
+ sleep_after_50x_error_within_query: float = 10,
103
+ ):
104
+ self.urls = _ShareUrls(url)
105
+ self.user_agent = user_agent or _version.user_agent
106
+
107
+ # Transport to use in httpx client. Should only be defined in testing
108
+ self.transport = transport
109
+ # Time to wait for before first GET status after POST query
110
+ self.sleep_before_first_status_query = sleep_before_first_status_query
111
+ # Time to wait after 50x response within a query. These are typically
112
+ # transitory errors on the server side
113
+ self.sleep_after_50x_error_within_query = sleep_after_50x_error_within_query
114
+
115
+ def _get_client(self) -> httpx.Client:
116
+ """Initiaze new client."""
117
+ return httpx.Client(
118
+ base_url=self.urls.base_url,
119
+ follow_redirects=True,
120
+ timeout=60,
121
+ headers={
122
+ **self.urls.authorization_header,
123
+ "USER-AGENT": self.user_agent,
124
+ "ACCEPT-ENCODING": "gzip",
125
+ "ACCEPT": "application/json",
126
+ },
127
+ transport=self.transport,
128
+ )
129
+
130
+ def _init_query(self, timeout: Optional[float]) -> Query:
131
+ return Query(
132
+ self._get_client(),
133
+ Timeout(timeout),
134
+ )
135
+
136
+ def async_query(
137
+ self,
138
+ params: Optional[dict[str, Union[str, Sequence[str], int, float]]] = None,
139
+ timeout: Optional[float] = None,
140
+ ) -> httpx.Response:
141
+ """Execute 3-phase async query."""
142
+ qp = {**self.urls.qp, **(params or {})}
143
+ invalid_params = qp.keys() - self.allowed_params
144
+ if invalid_params:
145
+ raise ConfigError(f"Invalid query parameters: {invalid_params}")
146
+
147
+ with self._init_query(timeout) as query:
148
+ status_url = self._async_post_query(query, qp)
149
+ time.sleep(self.sleep_before_first_status_query)
150
+ result_url = self._async_get_result_url(query, status_url)
151
+ return self._async_get_result_response(query, result_url)
152
+
153
+ def _async_post_query(
154
+ self,
155
+ query: Query,
156
+ params: Optional[dict[str, Union[str, Sequence[str], int, float]]] = None,
157
+ ) -> str:
158
+ """POST async query.
159
+
160
+ Returns status url.
161
+ """
162
+ try:
163
+ response = query.client.post(url=self.urls.async_path, params=params)
164
+ query.post_url = response.request.url
165
+ except httpx.RequestError as error:
166
+ raise NetworkError(
167
+ f"Downloading {error.request.url} failed: {error}",
168
+ url=str(error.request.url),
169
+ )
170
+
171
+ if self._server_unavailable(response.status_code):
172
+ logger.debug(
173
+ f"Error posting job, retry later ({response.status_code} {response.text})"
174
+ )
175
+ # Server error on initial post -> suggest retrying whole query later again
176
+ raise Retry(
177
+ after=response.headers.get("Retry-After", 10), url=str(response.url)
178
+ )
179
+ elif (invalid_inputs := self._invalid_input_error(response)) is not None:
180
+ raise ConfigError(invalid_inputs, url=str(response.url))
181
+ elif response.status_code == 500:
182
+ raise ServerError(
183
+ f"Sharing API server error 500 for submit, {response.text}",
184
+ url=str(response.url),
185
+ )
186
+ elif response.status_code != 202:
187
+ raise NetworkError(
188
+ f"Unexpected status {response.status_code} for submit, {response.text}",
189
+ url=str(response.request.url),
190
+ )
191
+
192
+ try:
193
+ return response.headers["Location"]
194
+ except KeyError:
195
+ raise Error("Location header missing from response", url=str(response.url))
196
+
197
+ def _async_get_result_url(self, query: Query, url: str) -> str:
198
+ """GET async result url from status url."""
199
+ while True:
200
+ try:
201
+ response = query.client.get(url=url, follow_redirects=False)
202
+ except httpx.RequestError as error:
203
+ raise NetworkError(
204
+ f"Downloading {error.request.url} failed: {error}",
205
+ url=str(error.request.url),
206
+ )
207
+
208
+ if response.status_code == 302:
209
+ # results are ready
210
+ break
211
+ elif response.status_code == 202:
212
+ time.sleep(int(response.headers.get("Retry-After", 1)))
213
+ elif response.status_code == 500:
214
+ raise ServerError(
215
+ f"Sharing API server error 500 getting status, {response.text}",
216
+ url=str(response.url),
217
+ )
218
+ elif self._server_unavailable(response.status_code):
219
+ logger.debug(
220
+ f"Error getting status, try again after {self.sleep_after_50x_error_within_query} secs ({response.status_code} {response.text})"
221
+ )
222
+ time.sleep(self.sleep_after_50x_error_within_query)
223
+ elif response.status_code == 410:
224
+ if response.headers.get("X-STATUS") == "Job expired":
225
+ raise Error("The query has expired", url=str(response.url))
226
+ else:
227
+ raise Retry(
228
+ "Job no longer exists (may have become stale)",
229
+ url=str(response.url),
230
+ )
231
+ else:
232
+ raise Retry(
233
+ f"Unexpected status {response.status_code} loading results, {response.text}",
234
+ url=str(response.url),
235
+ )
236
+
237
+ try:
238
+ return response.headers["Location"]
239
+ except KeyError:
240
+ raise Error("Location header missing from response", url=str(response.url))
241
+
242
+ def _async_get_result_response(self, query: Query, url: str) -> httpx.Response:
243
+ """GET async result response."""
244
+ while True:
245
+ try:
246
+ response = query.client.get(url)
247
+ except httpx.RequestError as error:
248
+ raise NetworkError(
249
+ f"Downloading {error.request.url} failed: {error}",
250
+ url=str(error.request.url),
251
+ )
252
+
253
+ if response.status_code == 200:
254
+ break
255
+ elif response.status_code == 410:
256
+ if response.headers.get("X-STATUS") == "Job expired":
257
+ raise Error("The query has expired", url=str(response.url))
258
+ else:
259
+ raise Retry(
260
+ "Results have been fetched already", url=str(response.url)
261
+ )
262
+ elif response.status_code == 500:
263
+ raise ServerError(
264
+ f"Sharing API server error 500 fetching results, {response.text}",
265
+ url=str(response.url),
266
+ )
267
+ elif self._server_unavailable(response.status_code):
268
+ # sleep only 1 sec since results are stored only for a limited time
269
+ logger.debug(
270
+ f"Error getting results, try again after {self.sleep_after_50x_error_within_query} secs ({response.status_code} {response.text})"
271
+ )
272
+ time.sleep(self.sleep_after_50x_error_within_query)
273
+ elif self._is_invalid_token_error(response):
274
+ assert query.post_url is not None # for mypy
275
+ raise InvalidTokenError(
276
+ query.post_url.params.get("token"), url=str(response.url)
277
+ )
278
+ else:
279
+ raise Retry(
280
+ f"Unexpected status {response.status_code} fetching results, {response.text}",
281
+ url=str(response.url),
282
+ )
283
+
284
+ return response
285
+
286
+ @staticmethod
287
+ def _server_unavailable(status: int) -> bool:
288
+ """Does status mean server is unavailable?
289
+
290
+ 502 / 503 / 504 typically overloaded system. All of these should be
291
+ transient on a valid hub url.
292
+ """
293
+ return status in (502, 503, 504)
294
+
295
+ @staticmethod
296
+ def _is_invalid_token_error(response: httpx.Response) -> bool:
297
+ """Does response contain "invalid token" error.
298
+
299
+ {"title": "400 Bad Request", "errors": [{"type": "storage", "key": "token", "message": "Invalid token: foo"}]}%
300
+ """
301
+ if response.status_code != 400:
302
+ return False
303
+
304
+ try:
305
+ for error in response.json().get("errors", ()):
306
+ if error.get("key") == "token" and error.get("message", "").startswith(
307
+ "Invalid token"
308
+ ):
309
+ return True
310
+ except json.decoder.JSONDecodeError:
311
+ return False
312
+
313
+ return False
314
+
315
+ @staticmethod
316
+ def _invalid_input_error(response: httpx.Response) -> Optional[Any]:
317
+ """If response contains "invalid inputs" error, return it.
318
+
319
+ {'title': '400 Invalid input(s)', 'description': '2 invalid input(s)', 'errors': [{'key': 'start', 'type': 'query validation', 'message': "Invalid start: ['foo']"}, {'key': 'startt', 'type': 'query validation', 'message': 'Unknown parameter: startt'}]}
320
+ """
321
+ if response.status_code != 400:
322
+ return None
323
+
324
+ try:
325
+ d = response.json()
326
+ if d.get("title") == "400 Invalid input(s)":
327
+ return d.get("errors", [])
328
+ except json.decoder.JSONDecodeError:
329
+ return None
330
+
331
+ return None
332
+
333
+
334
+ @dataclass
335
+ class _ShareUrls:
336
+ """Share url handling."""
337
+
338
+ base_url: str
339
+ sync_path: str
340
+ async_path: str
341
+ authorization_header: dict[str, str]
342
+ qp: dict[str, list[str]]
343
+
344
+ def __init__(self, sync_url: str):
345
+ """Build urls from a sync url.
346
+
347
+ The provided sync url must have apikey query parameter, which will be
348
+ separated from the url and used in the Authorization header on async api
349
+ queries. All the query parameters are parsed and saved in `qp`, this is
350
+ to allow merging them with the parameters provided in the url.
351
+ (by default httpx overrides qp's in url)
352
+
353
+ >>> _ShareUrls("https://example.com/shares/v2/share-id?apikey=api-key&filter=foo=bar")
354
+ _ShareUrls(base_url='https://example.com', sync_path='/shares/v2/share-id', async_path='/shares/v2/async/share-id', authorization_header={'Authorization': 'token api-key'}, qp={'filter': ['foo=bar']})
355
+ >>> str(_ShareUrls("https://example.com/shares/v2/share-id?apikey=api-key&filter=foo=bar"))
356
+ 'https://example.com//shares/v2/async/share-id'
357
+ >>> _ShareUrls("https://example.com/shares/v2/share-id?filter=foo=bar")
358
+ Traceback (most recent call last):
359
+ arcticsecurity.sharing_api.errors.ConfigError: API share url must have apikey parameter
360
+ """
361
+ o = urlparse(sync_url)
362
+ qp = parse_qs(o.query, keep_blank_values=True)
363
+
364
+ if "apikey" not in qp:
365
+ raise ConfigError("API share url must have apikey parameter")
366
+ elif len(qp["apikey"]) > 1:
367
+ raise ConfigError("API share url must have exactly one apikey parameter")
368
+
369
+ self.base_url = urlunparse(o._replace(path="", query=""))
370
+ self.sync_path = o.path
371
+ self.async_path = re.sub(r"^/shares(/v2)?", "/shares/v2/async", o.path)
372
+ self.authorization_header = {"Authorization": f"token {qp.pop('apikey')[0]}"}
373
+ self.qp = qp
374
+
375
+ def __str__(self) -> str:
376
+ return f"{self.base_url}/{self.async_path}"
@@ -0,0 +1,11 @@
1
+ """
2
+ Library version info.
3
+ """
4
+
5
+ import platform as platform_library
6
+
7
+ library_name = "sharing-api"
8
+ version = "0.1.0"
9
+ platform = platform_library.platform()
10
+ python_version = platform_library.python_version()
11
+ user_agent = f"{library_name}-v{version}-{platform}-py{python_version}"
@@ -0,0 +1,84 @@
1
+ """
2
+ Sharing API errors
3
+ """
4
+
5
+ from typing import Any, Optional, Union
6
+
7
+ __all__ = [
8
+ "Error",
9
+ "ConfigError",
10
+ "InvalidTokenError",
11
+ "ServerError",
12
+ "NetworkError",
13
+ "TimeoutError",
14
+ "Retry",
15
+ ]
16
+
17
+
18
+ class Error(Exception):
19
+ """Base class for all errors."""
20
+
21
+ def __init__(self, *args: Any, url: Optional[str] = None, **kwargs: Any):
22
+ self.url = url
23
+ super().__init__(*args, **kwargs)
24
+
25
+
26
+ class ConfigError(Error):
27
+ """User error in query configuration.
28
+
29
+ This error may be raised by this library or by the server.
30
+ """
31
+
32
+ pass
33
+
34
+
35
+ class InvalidTokenError(Error):
36
+ """The server responded with "invalid token" error."""
37
+
38
+ pass
39
+
40
+
41
+ class ServerError(Error):
42
+ """The server responded with a error message.
43
+
44
+ This results from a server which doesn't operate properly at the moment.
45
+ """
46
+
47
+ pass
48
+
49
+
50
+ class NetworkError(Error):
51
+ """Error communicating with the sharing API server.
52
+
53
+ This results from error communicating the with the sharing API server.
54
+ """
55
+
56
+ pass
57
+
58
+
59
+ class TimeoutError(Error):
60
+ """Query to sharing API server timed out.
61
+
62
+ The three-phase query to the sharing API exceeded the configured timeout.
63
+ """
64
+
65
+ pass
66
+
67
+
68
+ class Retry(NetworkError):
69
+ """Network error that should be retried.
70
+
71
+ This is raised from errors that are likely transient and may succeed if retried
72
+ after some time.
73
+ """
74
+
75
+ def __init__(self, *args: Any, after: Union[str, int, None] = None, **kwargs: Any):
76
+ super().__init__(*args, **kwargs)
77
+
78
+ if after is None:
79
+ self.after = None
80
+ else:
81
+ try:
82
+ self.after = int(after)
83
+ except ValueError:
84
+ self.after = None
@@ -0,0 +1,349 @@
1
+ """
2
+ Sharing API client.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from collections.abc import Iterable, Iterator
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from typing import Any, Optional, Union
12
+
13
+ from ._api_client import _ApiClient
14
+ from .errors import ConfigError, ServerError
15
+
16
+ logger = logging.getLogger(__name__)
17
+ Event = dict[str, Union[str, list[str]]]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class SyncReadResponse:
22
+ events: list[Event]
23
+ token: Optional[str]
24
+ has_more: bool
25
+
26
+
27
+ class Sync:
28
+ """Sync events from sharing API."""
29
+
30
+ allowed_user_provided_qps = {
31
+ "filter",
32
+ "projection",
33
+ }
34
+
35
+ def __init__(
36
+ self,
37
+ url: str,
38
+ *,
39
+ filter: Optional[str] = None,
40
+ projection: Optional[Iterable[str]] = None,
41
+ start: Union[datetime, int, float, None] = None,
42
+ **kwargs: Any,
43
+ ):
44
+ """
45
+ Initialize Sync class.
46
+
47
+ Args:
48
+ url: Sharing API url, must include `apikey` query parameter.
49
+ filter: Rulelang filter.
50
+ projection: List of event field names to include in the results. Note that the list of keys provided by the server can only be limited by this parameter.
51
+ start: Start time for the initial query. Can also be set with seek(). Default value `None` means current time. Positive numbers are interpret as epoch time. Non-positive numbers are interpret as that many seconds in the past.
52
+ """
53
+
54
+ # Check args
55
+ if not isinstance(url, str):
56
+ raise TypeError(f"url must be string not {type(url)}")
57
+
58
+ if not (filter is None or isinstance(filter, str)):
59
+ raise TypeError(f"filter must be string or None, not {type(filter)}")
60
+
61
+ if not (
62
+ projection is None
63
+ or (
64
+ isinstance(projection, Iterable)
65
+ and not isinstance(projection, str)
66
+ and all(isinstance(x, str) for x in projection)
67
+ )
68
+ ):
69
+ raise TypeError(
70
+ "projection must be a list of key names, each an instance of str"
71
+ )
72
+
73
+ user_agent = kwargs.pop("user_agent", None)
74
+ if not (user_agent is None or isinstance(user_agent, str)):
75
+ raise TypeError(
76
+ f"user_agent must be string or None, not {type(user_agent)}"
77
+ )
78
+
79
+ if kwargs:
80
+ raise ValueError(f"Unknown parameter(s) {tuple(kwargs.keys())}")
81
+
82
+ # Initialize client
83
+ self.api_client = _ApiClient(url, user_agent=user_agent)
84
+
85
+ invalid_qps_in_url = (
86
+ self.api_client.urls.qp.keys() - self.allowed_user_provided_qps
87
+ )
88
+ if invalid_qps_in_url:
89
+ raise ConfigError(f"Invalid qps in url: {invalid_qps_in_url}")
90
+
91
+ self.qp = {
92
+ "filter": filter,
93
+ "projection": projection,
94
+ "sort": "_id",
95
+ }
96
+
97
+ self.start = self._seek(start)
98
+
99
+ def read(
100
+ self,
101
+ *,
102
+ token: Optional[str] = None,
103
+ pagesize: int = 1000,
104
+ timeout: Optional[float] = 600,
105
+ ) -> SyncReadResponse:
106
+ """Sync events from sharing API
107
+
108
+ Events are returned sorted by insertion time.
109
+
110
+ Args:
111
+ token: Continuation token. If not given, the start time set earlier is used.
112
+ pagesize: Maximum number of events to return.
113
+ timeout: Timeout for the operation in [s].
114
+
115
+ Returns:
116
+ Batch of events and a continuation token for the next query.
117
+ """
118
+ if not (token is None or isinstance(token, str)):
119
+ raise TypeError(f"token must be string or None, not {type(token)}")
120
+
121
+ if not isinstance(pagesize, int):
122
+ raise TypeError(f"pagesize must be int not {type(pagesize)}")
123
+
124
+ if not (timeout is None or isinstance(timeout, (int, float))):
125
+ raise TypeError(f"timeout must be float or None, not {type(timeout)}")
126
+
127
+ qp = _remove_none_values(
128
+ {
129
+ **self.qp,
130
+ **{
131
+ "token": token,
132
+ "limit": pagesize if pagesize != 0 else None,
133
+ },
134
+ }
135
+ )
136
+
137
+ if "token" not in qp:
138
+ qp["start"] = self.start
139
+
140
+ resp = self.api_client.async_query(qp, timeout=timeout)
141
+
142
+ try:
143
+ token = resp.headers["x-next-token"]
144
+ has_more = True
145
+ except KeyError:
146
+ token = resp.headers.get("x-last-inserted-token", None)
147
+ has_more = False
148
+
149
+ try:
150
+ return SyncReadResponse(
151
+ resp.json(),
152
+ token,
153
+ has_more,
154
+ )
155
+ except json.decoder.JSONDecodeError:
156
+ logger.error(f"Invalid response from server {resp.content=}")
157
+ raise ServerError(f"Invalid response from server {resp}")
158
+
159
+ def seek(self, ts: Union[datetime, int, float, None]) -> None:
160
+ """Set sync start to specific time.
161
+
162
+ Args:
163
+ ts: See start in __init__.
164
+ """
165
+ self.start = self._seek(ts)
166
+
167
+ def _seek(self, ts: Union[datetime, int, float, None]) -> float:
168
+ """Set sync start to specific time."""
169
+ if not (ts is None or isinstance(ts, (int, float, datetime))):
170
+ raise TypeError(f"ts must be int, float, datetime or None, not {type(ts)}")
171
+
172
+ if ts is None:
173
+ return time.time()
174
+ elif isinstance(ts, (int, float)):
175
+ return float(ts)
176
+ else:
177
+ return ts.timestamp()
178
+
179
+ def __str__(self) -> str:
180
+ return f"Sync({self.api_client.urls})"
181
+
182
+
183
+ class Query:
184
+ """Query events from sharing API."""
185
+
186
+ allowed_user_provided_qps = {
187
+ "filter",
188
+ "projection",
189
+ "limit",
190
+ "start",
191
+ "end",
192
+ "reverse",
193
+ }
194
+
195
+ def __init__(self, url: str, **kwargs: Any):
196
+ """
197
+ Initialize Query class.
198
+
199
+ Args:
200
+ url: Sharing API url, must include `apikey` query parameter.
201
+ """
202
+
203
+ # Check args
204
+ if not isinstance(url, str):
205
+ raise TypeError(f"url must be string not {type(url)}")
206
+
207
+ user_agent = kwargs.pop("user_agent", None)
208
+ if not (user_agent is None or isinstance(user_agent, str)):
209
+ raise TypeError(
210
+ f"user_agent must be string or None, not {type(user_agent)}"
211
+ )
212
+
213
+ if kwargs:
214
+ raise ValueError(f"Unknown parameter(s) {tuple(kwargs.keys())}")
215
+
216
+ # Initialize client
217
+ self.api_client = _ApiClient(url, user_agent=user_agent)
218
+
219
+ invalid_qps_in_url = (
220
+ self.api_client.urls.qp.keys() - self.allowed_user_provided_qps
221
+ )
222
+ if invalid_qps_in_url:
223
+ raise ConfigError(f"Invalid qps in url: {invalid_qps_in_url}")
224
+
225
+ def query(
226
+ self,
227
+ *,
228
+ filter: Optional[str] = None,
229
+ projection: Optional[Iterable[str]] = None,
230
+ start: Union[datetime, int, float, None] = None,
231
+ end: Union[datetime, int, float, None] = None,
232
+ reverse: bool = False,
233
+ max_events: int = 0,
234
+ timeout: Optional[float] = 600,
235
+ **kwargs: Any,
236
+ ) -> Iterator[Event]:
237
+ """Query sharing API.
238
+
239
+ Events are returned sorted by timestamp.
240
+
241
+ Args:
242
+ filter: Rulelang filter.
243
+ projection: List of event field names to include in the results. Note that the list of keys provided by the server can only be limited by this parameter.
244
+ start: Start time for the query. Default value `None` means forever. Positive numbers are interpret as epoch time. Non-positive numbers are interpret as that many seconds in the past.
245
+ end: End time for the query. Default value `None` means current time. Positive numbers are interpret as epoch time. Non-positive numbers are interpret as that many seconds in the past.
246
+ reverse: Return events in reverse order.
247
+ max_events: Maximum number of events to generate.
248
+ timeout: Timeout for one sharing API query fetching more events in [s].
249
+
250
+ Returns:
251
+ Generator yielding events matching the query.
252
+ """
253
+ if not (filter is None or isinstance(filter, str)):
254
+ raise TypeError(f"filter must be string or None, not {type(filter)}")
255
+
256
+ if not (
257
+ projection is None
258
+ or (
259
+ isinstance(projection, Iterable)
260
+ and not isinstance(projection, str)
261
+ and all(isinstance(x, str) for x in projection)
262
+ )
263
+ ):
264
+ raise TypeError(
265
+ "projection must be a list of key names, each an instance of str"
266
+ )
267
+
268
+ if not (start is None or isinstance(start, (int, float, datetime))):
269
+ raise TypeError(
270
+ f"start must be int, float, datetime or None, not {type(start)}"
271
+ )
272
+
273
+ if not (end is None or isinstance(end, (int, float, datetime))):
274
+ raise TypeError(
275
+ f"end must be int, float, datetime or None, not {type(end)}"
276
+ )
277
+
278
+ if not isinstance(reverse, bool):
279
+ raise TypeError(f"reverse must be bool, not {type(bool)}")
280
+
281
+ if not isinstance(max_events, int):
282
+ raise TypeError(f"max_events must be int, not {type(max_events)}")
283
+
284
+ if not (timeout is None or isinstance(timeout, (int, float))):
285
+ raise TypeError(f"timeout must be float or None, not {type(timeout)}")
286
+
287
+ pagesize = kwargs.pop("pagesize", 1000)
288
+ if not isinstance(pagesize, int):
289
+ raise TypeError(f"pagesize must be int, not {type(kwargs.get('pagesize'))}")
290
+
291
+ if kwargs:
292
+ raise ValueError(f"Unknown parameter(s) {tuple(kwargs.keys())}")
293
+
294
+ qp = _remove_none_values(
295
+ {
296
+ "filter": filter,
297
+ "projection": projection,
298
+ "start": _build_start_end(start),
299
+ "end": _build_start_end(end),
300
+ "reverse": "" if reverse else None,
301
+ "limit": pagesize,
302
+ }
303
+ )
304
+
305
+ more = True
306
+ token = None
307
+ n_events = 0
308
+
309
+ while more:
310
+ resp = self.api_client.async_query(qp, timeout=timeout)
311
+ events = resp.json()
312
+ logger.debug(f"queried, got {len(events)} events")
313
+
314
+ try:
315
+ token = resp.headers["x-next-token"]
316
+ except KeyError:
317
+ more = False
318
+ else:
319
+ qp["token"] = token
320
+ more = True
321
+
322
+ for event in events:
323
+ yield event
324
+ n_events += 1
325
+ if 0 < max_events <= n_events:
326
+ more = False
327
+ break
328
+
329
+ def __str__(self) -> str:
330
+ return f"Query({self.api_client.urls})"
331
+
332
+
333
+ def query(url: str, **kwargs: Any) -> Iterable[Event]:
334
+ """Shortcut to Query(url).query()."""
335
+ return Query(url).query(**kwargs)
336
+
337
+
338
+ def _remove_none_values(d: dict[str, Optional[Any]]) -> dict[str, Any]:
339
+ return {k: v for k, v in d.items() if v is not None}
340
+
341
+
342
+ def _build_start_end(ts: Union[datetime, int, float, None]) -> Union[int, float, None]:
343
+ """Build start / end query parameter."""
344
+ if ts is None:
345
+ return None
346
+ elif isinstance(ts, (int, float)):
347
+ return ts
348
+ else:
349
+ return ts.timestamp()
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: arcticsecurity
3
+ Version: 0.1.0
4
+ Summary: Library to access Arctic Security APIs
5
+ Author: Arctic Security
6
+ Author-email: Arctic Security <pypi@arcticsecurity.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Dist: httpx>=0.28.1
24
+ Requires-Python: >=3.9
25
+ Project-URL: Home, https://github.com/arcticsecurity/arcticsecurity-python
26
+ Project-URL: Issues, https://github.com/arcticsecurity/arcticsecurity-python/issues
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Arctic Security Library
30
+
31
+ A Python library to access Arctic Security's Sharing API endpoints and retrieve event data.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install arcticsecurity
37
+ ```
38
+
39
+ Python >= 3.9 is required.
40
+
41
+ ## Quick Start
42
+
43
+ The library supports two distinct ways to retrieve events from the Sharing API: `Query` and `Sync`.
44
+
45
+ ### Query
46
+
47
+ Use the `Query` class to perform one-off queries for specific events.
48
+
49
+ ```python
50
+ from arcticsecurity.sharing_api import Query
51
+
52
+ url = "https://example.com/shares/v2/share-id?apikey=YOUR_API_KEY"
53
+ for event in Query(url).query(filter='"network owner"="Example Co"', max_events=50):
54
+ print(event)
55
+ ```
56
+
57
+ ### Sync
58
+
59
+ Use the `Sync` class to reliably fetch all event data. It uses pagination with opaque continuation tokens to ensure no events are missed.
60
+
61
+ ```python
62
+ from arcticsecurity.sharing_api import Sync
63
+
64
+ url = "https://example.com/shares/v2/share-id?apikey=YOUR_API_KEY"
65
+ sync = Sync(url, filter='"network owner"="Example Co"')
66
+
67
+ # Fetch token from previous run
68
+ token = ...
69
+
70
+ while True:
71
+ res = sync.read(token=token, pagesize=1000)
72
+ if not res.events:
73
+ break
74
+ # process res.events...
75
+
76
+ token = res.token
77
+
78
+ if not res.has_more:
79
+ break
80
+
81
+ # store the token for the next run
82
+ ```
83
+
84
+ ## Common features
85
+
86
+ - Robust error handling with specific exceptions (`ConfigError`, `NetworkError`, `Retry`, `TimeoutError`, `InvalidTokenError`, `ServerError`).
87
+ - Optional timeout and user-agent customization
88
+
89
+ ## Versioning
90
+
91
+ Follows [Semantic Versioning v2](https://semver.org/spec/v2.0.0.html).
92
+
93
+ ## Development
94
+
95
+ [uv](https://docs.astral.sh/uv/) is required.
96
+
97
+ ```bash
98
+ uv sync --group dev
99
+
100
+ uv run ruff check .
101
+ uv run pytest --doctest-modules
102
+
103
+ uv run mypy --config-file pyproject.toml -p arcticsecurity
104
+ uv run ty check
105
+
106
+ uv run mkdocs build
107
+ uv run mkdocs serve
108
+ ```
109
+
110
+ ## Contributing
111
+
112
+ Issues and pull requests are welcome. Please add tests for any new behavior.
113
+
114
+ ## Status
115
+
116
+ Experimental (`0.1.x`); the API may evolve. Pin the library version for production use.
@@ -0,0 +1,11 @@
1
+ arcticsecurity/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ arcticsecurity/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ arcticsecurity/sharing_api/__init__.py,sha256=B0TgFkilX9dAaEyHRi1hO4CMX_jr5VFzFR2JICNCYT8,91
4
+ arcticsecurity/sharing_api/_api_client.py,sha256=8i_8kvsGfnufMqH1lHHHuE1q3V5XgdOK5deWSqX-B3I,13563
5
+ arcticsecurity/sharing_api/_version.py,sha256=Icv4zNjQLmDfNstVPJvkTQ1TO0T-Iac7S79XmZsG2BA,277
6
+ arcticsecurity/sharing_api/errors.py,sha256=elWNc3v2d8GlXtySsQYvCL25QBEa8JCjw-ySXpbmx6Q,1711
7
+ arcticsecurity/sharing_api/sharing_api.py,sha256=a7jivPbfddlaVe1uKAqchbCttuMHWs6EYgP3Jeg6sr8,11336
8
+ arcticsecurity-0.1.0.dist-info/licenses/LICENSE,sha256=zwVs8Z8wzlOjkzY9dM65qoFgzhRTrvOHKy4wFVPlxQg,1072
9
+ arcticsecurity-0.1.0.dist-info/WHEEL,sha256=iCTolw4aw2dP3yfM-EQCGTDsFCXL_ymmbYnBRVH7plA,81
10
+ arcticsecurity-0.1.0.dist-info/METADATA,sha256=qqUSUP0k9F-uLgVBFsrQ1_irFjZIdMxmcLXLf9YIGBA,3189
11
+ arcticsecurity-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.11
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arctic Security
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.