pyhubblenetwork 0.0.2__tar.gz → 0.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyhubblenetwork
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Hubble SDK host-side tools
5
5
  Author-email: Paul Buckley <paul@hubble.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyhubblenetwork"
7
- version = "0.0.2"
7
+ version = "0.0.3"
8
8
  requires-python = ">=3.9"
9
9
  authors = [
10
10
  { name="Paul Buckley", email="paul@hubble.com" },
@@ -11,6 +11,8 @@ from .packets import Location, EncryptedPacket, DecryptedPacket
11
11
  from .device import Device
12
12
  from .org import Organization
13
13
  from .crypto import decrypt
14
+ from .errors import InvalidCredentialsError
15
+ from .cloud import Credentials, Environment
14
16
 
15
17
  __all__ = [
16
18
  "ble",
@@ -21,4 +23,7 @@ __all__ = [
21
23
  "DecryptedPacket",
22
24
  "Device",
23
25
  "Organization",
26
+ "Credentials",
27
+ "Environment",
28
+ "InvalidCredentialsError",
24
29
  ]
@@ -13,6 +13,8 @@ from hubblenetwork import Organization
13
13
  from hubblenetwork import Device, DecryptedPacket, EncryptedPacket
14
14
  from hubblenetwork import ble as ble_mod
15
15
  from hubblenetwork import decrypt
16
+ from hubblenetwork import cloud
17
+ from hubblenetwork import InvalidCredentialsError
16
18
 
17
19
 
18
20
  def _get_env_or_fail(name: str) -> str:
@@ -122,6 +124,36 @@ def cli() -> None:
122
124
  # top-level group; subcommands are added below
123
125
 
124
126
 
127
+ @cli.command("validate-credentials")
128
+ @click.option(
129
+ "--org-id",
130
+ "-o",
131
+ type=str,
132
+ envvar="HUBBLE_ORG_ID",
133
+ default=None,
134
+ show_default=False,
135
+ help="Organization ID (if not using HUBBLE_ORG_ID env var)",
136
+ )
137
+ @click.option(
138
+ "--token",
139
+ "-t",
140
+ type=str,
141
+ envvar="HUBBLE_API_TOKEN",
142
+ default=None,
143
+ show_default=False,
144
+ help="Token (if not using HUBBLE_API_TOKEN env var)",
145
+ )
146
+ def validate_credentials(org_id, token) -> None:
147
+ """Validate the given credentials"""
148
+ # subgroup for organization-related commands
149
+ credentials = cloud.Credentials(org_id, token)
150
+ env = cloud.get_env_from_credentials(credentials)
151
+ if env:
152
+ click.echo(f'Valid credentials (env="{env.name}")')
153
+ else:
154
+ click.secho(f"Invalid credentials!", fg="red", err=True)
155
+
156
+
125
157
  @cli.group()
126
158
  def ble() -> None:
127
159
  """BLE utilities."""
@@ -182,8 +214,10 @@ def ble_scan(timeout, ingest: bool = False, key: str = None) -> None:
182
214
  if ingest:
183
215
  click.echo("[INFO] Ingesting packet(s) into the backend... ", nl=False)
184
216
  org = Organization(
185
- org_id=_get_env_or_fail("HUBBLE_ORG_ID"),
186
- api_token=_get_env_or_fail("HUBBLE_API_TOKEN"),
217
+ cloud.Credentials(
218
+ org_id=_get_env_or_fail("HUBBLE_ORG_ID"),
219
+ api_token=_get_env_or_fail("HUBBLE_API_TOKEN"),
220
+ )
187
221
  )
188
222
  for pkt in pkts:
189
223
  org.ingest_packet(pkt)
@@ -212,21 +246,14 @@ pass_orgcfg = click.make_pass_decorator(Organization, ensure=True)
212
246
  show_default=False,
213
247
  help="Token (if not using HUBBLE_API_TOKEN env var)",
214
248
  )
215
- @click.option(
216
- "--url",
217
- "-u",
218
- type=str,
219
- envvar="HUBBLE_BASE_URL",
220
- default=None,
221
- show_default=False,
222
- help="Base URL to override production (if not using HUBBLE_BASE_URL env var)",
223
- )
224
249
  @click.pass_context
225
- def org(ctx, org_id, token, url) -> None:
250
+ def org(ctx, org_id, token) -> None:
226
251
  """Organization utilities."""
227
252
  # subgroup for organization-related commands
228
- ctx.obj = Organization(org_id=org_id, api_token=token)
229
- ctx.obj.base_url = url
253
+ try:
254
+ ctx.obj = Organization(cloud.Credentials(org_id=org_id, api_token=token))
255
+ except InvalidCredentialsError as e:
256
+ raise click.BadParameter(str(e))
230
257
 
231
258
 
232
259
  @org.command("info")
@@ -1,5 +1,6 @@
1
1
  # hubble/cloud_api.py
2
2
  from __future__ import annotations
3
+ from dataclasses import dataclass
3
4
  import httpx
4
5
  import time
5
6
  import base64
@@ -13,11 +14,23 @@ from .errors import (
13
14
  raise_for_response,
14
15
  )
15
16
 
16
- ENVIRONMENTS = {
17
- "PROD": "https://api.hubble.com",
18
- "TESTING": "https://api-testing.hubblenetwork.io",
19
- }
20
- default_env_url = None
17
+
18
+ @dataclass(frozen=True)
19
+ class Environment:
20
+ name: str
21
+ url: str
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Credentials:
26
+ org_id: str
27
+ api_token: str
28
+
29
+
30
+ _ENVIRONMENTS = [
31
+ Environment("PROD", "https://api.hubble.com"),
32
+ Environment("TESTING", "https://api-testing.hubblenetwork.io"),
33
+ ]
21
34
 
22
35
 
23
36
  def _auth_headers(api_token: str) -> dict[str, str]:
@@ -28,69 +41,64 @@ def _auth_headers(api_token: str) -> dict[str, str]:
28
41
  }
29
42
 
30
43
 
31
- def _list_devices_endpoint(org_id: str) -> str:
32
- return f"/org/{org_id}/devices"
44
+ def _list_devices_endpoint(credentials: Credentials) -> str:
45
+ return f"/org/{credentials.org_id}/devices"
33
46
 
34
47
 
35
- def _register_device_endpoint(org_id: str) -> str:
36
- return f"/v2/org/{org_id}/devices"
48
+ def _register_device_endpoint(credentials: Credentials) -> str:
49
+ return f"/v2/org/{credentials.org_id}/devices"
37
50
 
38
51
 
39
- def _retrieve_org_packets_endpoint(org_id: str) -> str:
40
- return f"/org/{org_id}/packets"
52
+ def _retrieve_org_packets_endpoint(credentials: Credentials) -> str:
53
+ return f"/org/{credentials.org_id}/packets"
41
54
 
42
55
 
43
- def _ingest_packets_endpoint(org_id: str) -> str:
44
- return f"/org/{org_id}/packets"
56
+ def _ingest_packets_endpoint(credentials: Credentials) -> str:
57
+ return f"/org/{credentials.org_id}/packets"
45
58
 
46
59
 
47
- def _update_device_endpoint(org_id: str, device_id: str) -> str:
48
- return f"/org/{org_id}/devices/{device_id}"
60
+ def _update_device_endpoint(credentials: Credentials, device_id: str) -> str:
61
+ return f"/org/{credentials.org_id}/devices/{device_id}"
49
62
 
50
63
 
51
- def _retrive_org_metadata_endpoint(org_id: str) -> str:
52
- return f"/org/{org_id}"
64
+ def _retrieve_org_metadata_endpoint(credentials: Credentials) -> str:
65
+ return f"/org/{credentials.org_id}"
53
66
 
54
67
 
55
- def set_env(env: str) -> None:
56
- default_env_url = env
68
+ def _validate_key_endpoint(credentials: Credentials) -> str:
69
+ return f"/org/{credentials.org_id}/check"
57
70
 
58
71
 
59
72
  def cloud_request(
73
+ *,
60
74
  method: str,
61
75
  path: str,
62
- *,
63
- api_token: Optional[str] = None,
76
+ env: Environment,
77
+ credentials: Optional[credentials] = None,
64
78
  json: Any = None,
65
79
  timeout_s: float = 10.0,
66
80
  params: Optional[MutableMapping[str, Any]] = None,
67
- base_url: Optional[str] = None,
68
81
  ) -> Any:
69
82
  """
70
83
  Make a single HTTP request to the Hubble Cloud API and return parsed JSON.
71
84
 
72
85
  - `method`: "GET", "POST", etc.
73
86
  - `path`: endpoint path (e.g., "/devices" or "orgs/{id}/devices")
74
- - `api_token`: API token for auth (optional, but recommended)
75
- - `org_id`: if provided, will be added as query param `orgId=<org_id>`
76
- (skip or embed in `path` if your endpoint uses a path param instead)
87
+ - `credentials`: Credentials to use for this call
88
+ - `env`: Environment to call into (typically prod or testing)
77
89
  - `json`: request JSON body (for POST/PUT/PATCH)
78
90
  - `timeout_s`: request timeout in seconds
79
91
  - `params`: optional HTTP request parameters
80
- - `base_url`: URL to use in place of default production URL
81
92
  """
82
- path = path.lstrip("/")
83
- base_url = base_url if base_url is not None else default_env_url
84
- base_url = base_url.rstrip("/")
85
- url = f"{base_url}/api/{path}"
93
+ url = f"{env.url.rstrip('/')}/api/{path.lstrip('/')}"
86
94
 
87
95
  # headers
88
96
  headers: MutableMapping[str, str] = {
89
97
  "Accept": "application/json",
90
98
  "Content-Type": "application/json",
91
99
  }
92
- if api_token:
93
- headers["Authorization"] = f"Bearer {api_token}"
100
+ if credentials:
101
+ headers["Authorization"] = f"Bearer {credentials.api_token}"
94
102
 
95
103
  try:
96
104
  with httpx.Client(timeout=timeout_s) as client:
@@ -118,11 +126,25 @@ def cloud_request(
118
126
  raise BackendError(f"Non-JSON response from {url}") from e
119
127
 
120
128
 
129
+ def get_env_from_credentials(credentials: Credentials) -> Optional[Environment]:
130
+ for env in _ENVIRONMENTS:
131
+ try:
132
+ resp = cloud_request(
133
+ method="GET",
134
+ path=_validate_key_endpoint(credentials),
135
+ credentials=credentials,
136
+ env=env,
137
+ )
138
+ return env
139
+ except:
140
+ pass
141
+ return None
142
+
143
+
121
144
  def register_device(
122
145
  *,
123
- org_id: str,
124
- api_token: str,
125
- base_url: Optional[str] = None,
146
+ credentials: Credentials,
147
+ env: Environment,
126
148
  ) -> Any:
127
149
  """Create a new device and return it."""
128
150
  data = {
@@ -131,18 +153,17 @@ def register_device(
131
153
  }
132
154
  return cloud_request(
133
155
  method="POST",
134
- path=_register_device_endpoint(org_id),
135
- api_token=api_token,
156
+ env=env,
157
+ path=_register_device_endpoint(credentials),
158
+ credentials=credentials,
136
159
  json=data,
137
- base_url=base_url,
138
160
  )
139
161
 
140
162
 
141
163
  def update_device(
142
164
  *,
143
- org_id: str,
144
- api_token: str,
145
- base_url: Optional[str] = None,
165
+ credentials: Credentials,
166
+ env: Environment,
146
167
  name: str,
147
168
  device_id: str,
148
169
  ) -> Any:
@@ -153,15 +174,17 @@ def update_device(
153
174
  }
154
175
  return cloud_request(
155
176
  method="PATCH",
156
- path=_update_device_endpoint(org_id, device_id),
157
- api_token=api_token,
177
+ env=env,
178
+ path=_update_device_endpoint(credentials, device_id),
179
+ credentials=credentials,
158
180
  json=data,
159
- base_url=base_url,
160
181
  )
161
182
 
162
183
 
163
184
  def list_devices(
164
- *, org_id: str, api_token: str, base_url: Optional[str] = None
185
+ *,
186
+ credentials: Credentials,
187
+ env: Environment,
165
188
  ) -> list[Any]:
166
189
  """
167
190
  List devices for the org (keys typically omitted).
@@ -172,19 +195,18 @@ def list_devices(
172
195
  """
173
196
  return cloud_request(
174
197
  method="GET",
175
- path=_list_devices_endpoint(org_id),
176
- api_token=api_token,
177
- base_url=base_url,
198
+ env=env,
199
+ path=_list_devices_endpoint(credentials),
200
+ credentials=credentials,
178
201
  )
179
202
 
180
203
 
181
204
  def retrieve_packets(
182
205
  *,
183
- org_id: str,
184
- api_token: str,
185
- device_id: Optional[str] = None,
206
+ credentials: Credentials,
207
+ env: Environment,
208
+ device_id: str,
186
209
  days: int = 7,
187
- base_url: Optional[str] = None,
188
210
  ) -> Any:
189
211
  """Fetch decrypted packets for a device."""
190
212
  params = {"start": (int(time.time()) - (days * 24 * 60 * 60))}
@@ -192,19 +214,18 @@ def retrieve_packets(
192
214
  params["device_id"] = device_id
193
215
  return cloud_request(
194
216
  method="GET",
195
- path=_retrieve_org_packets_endpoint(org_id),
196
- api_token=api_token,
217
+ env=env,
218
+ path=_retrieve_org_packets_endpoint(credentials),
219
+ credentials=credentials,
197
220
  params=params,
198
- base_url=base_url,
199
221
  )
200
222
 
201
223
 
202
224
  def ingest_packet(
203
225
  *,
204
- org_id: str,
205
- api_token: str,
226
+ credentials: Credentials,
227
+ env: Environment,
206
228
  packet: EncryptedPacket,
207
- base_url: Optional[str] = None,
208
229
  ) -> Any:
209
230
  body = {
210
231
  "ble_locations": [
@@ -229,18 +250,17 @@ def ingest_packet(
229
250
  }
230
251
  return cloud_request(
231
252
  method="POST",
232
- path=_ingest_packets_endpoint(org_id),
233
- api_token=api_token,
253
+ env=env,
254
+ path=_ingest_packets_endpoint(credentials),
255
+ credentials=credentials,
234
256
  json=body,
235
- base_url=base_url,
236
257
  )
237
258
 
238
259
 
239
260
  def retrieve_org_metadata(
240
261
  *,
241
- org_id: str,
242
- api_token: str,
243
- base_url: Optional[str] = None,
262
+ credentials: Credentials,
263
+ env: Environment,
244
264
  ) -> Any:
245
265
  """
246
266
  Get organizational metadata
@@ -251,7 +271,7 @@ def retrieve_org_metadata(
251
271
  """
252
272
  return cloud_request(
253
273
  method="GET",
254
- path=_retrive_org_metadata_endpoint(org_id),
255
- api_token=api_token,
256
- base_url=base_url,
274
+ env=env,
275
+ path=_retrieve_org_metadata_endpoint(credentials),
276
+ credentials=credentials,
257
277
  )
@@ -40,6 +40,10 @@ class APITimeout(BackendError):
40
40
  """The API call exceeded its allowed timeout."""
41
41
 
42
42
 
43
+ class InvalidCredentialsError(BackendError):
44
+ """Invalid credentials passed in"""
45
+
46
+
43
47
  # Request/response semantics
44
48
  class ValidationError(BackendError):
45
49
  """The request was invalid (schema/semantics)."""
@@ -6,7 +6,7 @@ from typing import Optional, List
6
6
  from . import cloud
7
7
  from .packets import DecryptedPacket, Location
8
8
  from .device import Device
9
- from .errors import BackendError
9
+ from .errors import BackendError, InvalidCredentialsError
10
10
 
11
11
 
12
12
  class Organization:
@@ -15,43 +15,26 @@ class Organization:
15
15
  Used to manage devices and fetch decrypted packets from the backend.
16
16
  """
17
17
 
18
- org_id: str
19
- api_token: str
20
-
21
- api_base_url: str
22
- env: str
23
18
  name: str
24
19
 
25
- def __init__(self, org_id: str, api_token: str) -> str:
26
- self.org_id = org_id
27
- self.api_token = api_token
20
+ credentials: cloud.Credentials
21
+ env: cloud.Environment
28
22
 
29
- # Attempt to resolve environment (testing or prod)
30
- resp = None
31
- for env, url in cloud.ENVIRONMENTS.items():
32
- try:
33
- resp = cloud.retrieve_org_metadata(
34
- org_id=self.org_id, api_token=self.api_token, base_url=url
35
- )
36
- self.api_base_url = url
37
- self.env = env
38
- break
39
- except:
40
- pass
41
- if not resp:
42
- raise BackendError(f"Unable to determine environment")
43
- self.name = resp["name"]
23
+ def __init__(self, credentials: cloud.Credentials) -> str:
24
+ self.credentials = credentials
25
+ self.env = cloud.get_env_from_credentials(self.credentials)
26
+ if not self.env:
27
+ raise InvalidCredentialsError("Invalid credentials passed in.")
28
+ self.name = cloud.retrieve_org_metadata(
29
+ credentials=self.credentials, env=self.env
30
+ )["name"]
44
31
 
45
32
  def register_device(self) -> Device:
46
33
  """
47
34
  Register a new device in this organization and return it.
48
35
  Returned Device will have an ID and provisioned key.
49
36
  """
50
- resp = cloud.register_device(
51
- org_id=self.org_id,
52
- api_token=self.api_token,
53
- base_url=self.api_base_url,
54
- )
37
+ resp = cloud.register_device(credentials=self.credentials, env=self.env)
55
38
  # Currently, only registering a single device and taking the
56
39
  # first in the returned list
57
40
  device = resp["devices"][0]
@@ -63,11 +46,10 @@ class Organization:
63
46
  Returned Device will have an ID and provisioned key.
64
47
  """
65
48
  resp = cloud.update_device(
66
- org_id=self.org_id,
67
- api_token=self.api_token,
49
+ credentials=self.credentials,
50
+ env=self.env,
68
51
  name=name,
69
52
  device_id=device_id,
70
- base_url=self.api_base_url,
71
53
  )
72
54
  return Device(id=resp["id"], name=resp["name"])
73
55
 
@@ -79,9 +61,7 @@ class Organization:
79
61
  list[Device]
80
62
  """
81
63
 
82
- payload = cloud.list_devices(
83
- org_id=self.org_id, api_token=self.api_token, base_url=self.api_base_url
84
- )
64
+ payload = cloud.list_devices(credentials=self.credentials, env=self.env)
85
65
  raw_list = payload["devices"]
86
66
 
87
67
  # Turn each JSON object into a Device
@@ -96,11 +76,10 @@ class Organization:
96
76
  or None if none exists.
97
77
  """
98
78
  resp = cloud.retrieve_packets(
99
- org_id=self.org_id,
100
- api_token=self.api_token,
79
+ credentials=self.credentials,
80
+ env=self.env,
101
81
  device_id=device.id,
102
82
  days=days,
103
- base_url=self.api_base_url,
104
83
  )
105
84
  packets = []
106
85
  for packet in resp["packets"]:
@@ -126,8 +105,7 @@ class Organization:
126
105
 
127
106
  def ingest_packet(self, packet: EncryptedPacket) -> None:
128
107
  cloud.ingest_packet(
129
- org_id=self.org_id,
130
- api_token=self.api_token,
108
+ credentials=self.credentials,
109
+ env=self.env,
131
110
  packet=packet,
132
- base_url=self.api_base_url,
133
111
  )
File without changes