Skip to content

Commit 4aa5c39

Browse files
authored
release 🚚 (#52)
2 parents 9b3feb3 + 3fac92b commit 4aa5c39

File tree

7 files changed

+3937
-22
lines changed

7 files changed

+3937
-22
lines changed

‎.github/CODEOWNERS‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
* @leoparente @ltucker @mfiedorowicz
1+
* @jajeffries @leoparente @ltucker @mfiedorowicz

‎README.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pip install netboxlabs-diode-sdk
2424
* `DIODE_SENTRY_DSN` - Optional Sentry DSN for error reporting
2525
* `DIODE_CLIENT_ID` - Client ID for OAuth2 authentication
2626
* `DIODE_CLIENT_SECRET` - Client Secret for OAuth2 authentication
27+
* `DIODE_DRY_RUN_OUTPUT_DIR` - Directory where `DiodeDryRunClient` will write JSON files
2728

2829
### Example
2930

@@ -75,6 +76,34 @@ if __name__ == "__main__":
7576

7677
```
7778

79+
### Dry run mode
80+
81+
`DiodeDryRunClient` generates ingestion requests without contacting a Diode server. Requests are printed to stdout by default, or written to JSON files when `output_dir` (or the `DIODE_DRY_RUN_OUTPUT_DIR` environment variable) is specified. The `app_name` parameter serves as the filename prefix; if not provided, `dryrun` is used as the default prefix. The file name is suffixed with a nanosecond-precision timestamp, resulting in the format `<app_name>_<timestamp_ns>.json`.
82+
83+
```python
84+
from netboxlabs.diode.sdk import DiodeDryRunClient
85+
86+
with DiodeDryRunClient(app_name="my_app", output_dir="/tmp") as client:
87+
client.ingest([
88+
Entity(device="Device A"),
89+
])
90+
```
91+
92+
The produced file can later be ingested by a real Diode instance using
93+
`load_dryrun_entities` with a standard `DiodeClient`:
94+
95+
```python
96+
from netboxlabs.diode.sdk import DiodeClient, load_dryrun_entities
97+
98+
with DiodeClient(
99+
target="grpc://localhost:8080/diode",
100+
app_name="my-test-app",
101+
app_version="0.0.1",
102+
) as client:
103+
entities = list(load_dryrun_entities("my_app_92722156890707.json"))
104+
client.ingest(entities=entities)
105+
```
106+
78107
## Supported entities (object types)
79108

80109
* ASN

‎netboxlabs/diode/sdk/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
# Copyright 2024 NetBox Labs Inc
33
"""NetBox Labs, Diode - SDK."""
44

5-
from netboxlabs.diode.sdk.client import DiodeClient
5+
from netboxlabs.diode.sdk.client import (
6+
DiodeClient,
7+
DiodeDryRunClient,
8+
load_dryrun_entities,
9+
)
610

711
assert DiodeClient
12+
assert DiodeDryRunClient
13+
assert load_dryrun_entities

‎netboxlabs/diode/sdk/client.py‎

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@
99
import os
1010
import platform
1111
import ssl
12+
import sys
13+
import time
1214
import uuid
1315
from collections.abc import Iterable
16+
from pathlib import Path
1417
from urllib.parse import urlencode, urlparse
1518

1619
import certifi
1720
import grpc
1821
import sentry_sdk
22+
from google.protobuf.json_format import MessageToJson, ParseDict
1923

2024
from netboxlabs.diode.sdk.diode.v1 import ingester_pb2, ingester_pb2_grpc
2125
from netboxlabs.diode.sdk.exceptions import DiodeClientError, DiodeConfigError
@@ -27,11 +31,28 @@
2731
_DIODE_SENTRY_DSN_ENVVAR_NAME = "DIODE_SENTRY_DSN"
2832
_CLIENT_ID_ENVVAR_NAME = "DIODE_CLIENT_ID"
2933
_CLIENT_SECRET_ENVVAR_NAME = "DIODE_CLIENT_SECRET"
34+
_DRY_RUN_OUTPUT_DIR_ENVVAR_NAME = "DIODE_DRY_RUN_OUTPUT_DIR"
3035
_INGEST_SCOPE = "diode:ingest"
3136
_DEFAULT_STREAM = "latest"
3237
_LOGGER = logging.getLogger(__name__)
3338

3439

40+
def load_dryrun_entities(file_path: str | Path) -> Iterable[Entity]:
41+
"""Yield entities from a file with concatenated JSON messages."""
42+
path = Path(file_path)
43+
with path.open("r") as fh:
44+
request = json.load(fh)
45+
req_pb = ingester_pb2.IngestRequest()
46+
ParseDict(request, req_pb)
47+
yield from req_pb.entities
48+
49+
50+
class DiodeClientInterface:
51+
"""Runtime placeholder for the Diode client interface."""
52+
53+
pass
54+
55+
3556
def _load_certs() -> bytes:
3657
"""Loads cacert.pem."""
3758
with open(certifi.where(), "rb") as f:
@@ -82,7 +103,7 @@ def _get_optional_config_value(
82103
return value
83104

84105

85-
class DiodeClient:
106+
class DiodeClient(DiodeClientInterface):
86107
"""Diode Client."""
87108

88109
_name = "diode-sdk-python"
@@ -287,6 +308,77 @@ def _authenticate(self, scope: str):
287308
) + [("authorization", f"Bearer {access_token}")]
288309

289310

311+
class DiodeDryRunClient(DiodeClientInterface):
312+
"""Client that outputs ingestion requests instead of sending them."""
313+
314+
_name = "diode-sdk-python-dry-run"
315+
_version = version_semver()
316+
_app_name = None
317+
_app_version = None
318+
319+
def __init__(self, app_name: str = "dryrun", output_dir: str | None = None):
320+
"""Initiate a new dry run client."""
321+
self._output_dir = os.getenv(_DRY_RUN_OUTPUT_DIR_ENVVAR_NAME, output_dir)
322+
self._app_name = app_name
323+
324+
@property
325+
def name(self) -> str:
326+
"""Retrieve the name."""
327+
return self._name
328+
329+
@property
330+
def version(self) -> str:
331+
"""Retrieve the version."""
332+
return self._version
333+
334+
@property
335+
def app_name(self) -> str:
336+
"""Retrieve the app name."""
337+
return self._app_name
338+
339+
@property
340+
def output_dir(self) -> str | None:
341+
"""Retrieve the dry run output dir."""
342+
return self._output_dir
343+
344+
def __enter__(self):
345+
"""Enters the runtime context related to the channel object."""
346+
return self
347+
348+
def __exit__(self, exc_type, exc_value, exc_traceback):
349+
"""Exits the runtime context related to the channel object."""
350+
351+
def ingest(
352+
self,
353+
entities: Iterable[Entity | ingester_pb2.Entity | None],
354+
stream: str | None = _DEFAULT_STREAM,
355+
) -> ingester_pb2.IngestResponse:
356+
"""Ingest entities in dry run mode."""
357+
request = ingester_pb2.IngestRequest(
358+
stream=stream,
359+
id=str(uuid.uuid4()),
360+
producer_app_name=self._app_name,
361+
entities=entities,
362+
sdk_name=self.name,
363+
sdk_version=self.version,
364+
)
365+
366+
output = MessageToJson(request, preserving_proto_field_name=True)
367+
if self._output_dir:
368+
timestamp = time.perf_counter_ns()
369+
path = Path(self._output_dir)
370+
path.mkdir(parents=True, exist_ok=True)
371+
filename = "".join(
372+
c if c.isalnum() or c in ("_", "-") else "_" for c in self._app_name
373+
)
374+
file_path = path / f"{filename}_{timestamp}.json"
375+
with file_path.open("w") as fh:
376+
fh.write(output)
377+
else:
378+
print(output, file=sys.stdout)
379+
return ingester_pb2.IngestResponse()
380+
381+
290382
class _DiodeAuthentication:
291383
def __init__(
292384
self,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable
4+
from typing import Protocol, runtime_checkable
5+
6+
from netboxlabs.diode.sdk.diode.v1 import ingester_pb2
7+
from netboxlabs.diode.sdk.ingester import Entity
8+
9+
_DEFAULT_STREAM: str
10+
11+
@runtime_checkable
12+
class DiodeClientInterface(Protocol):
13+
"""Interface implemented by diode clients."""
14+
15+
@property
16+
def name(self) -> str: ...
17+
@property
18+
def version(self) -> str: ...
19+
def ingest(
20+
self,
21+
entities: Iterable[Entity | ingester_pb2.Entity | None],
22+
stream: str | None = _DEFAULT_STREAM,
23+
) -> ingester_pb2.IngestResponse: ...
24+
def __enter__(self) -> DiodeClientInterface: ...
25+
def __exit__(self, exc_type, exc_value, exc_traceback) -> None: ...

0 commit comments

Comments
 (0)