nanoping-api 11.1.0.dev0__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.
@@ -0,0 +1,29 @@
1
+ name: Publish
2
+ on:
3
+ push:
4
+ tags:
5
+ - "v*"
6
+ permissions:
7
+ contents: read
8
+ jobs:
9
+ publish:
10
+ name: Publish to PyPI
11
+ runs-on: ubuntu-latest
12
+ environment: pypi
13
+ permissions:
14
+ id-token: write
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v4
18
+ - name: Setup Python
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.12"
22
+ - name: Check that the tag matches the package version
23
+ run: test "v$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" = "$GITHUB_REF_NAME"
24
+ - name: Build
25
+ run: |
26
+ python -m pip install build
27
+ python -m build
28
+ - name: Publish
29
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,3 @@
1
+ __pycache__
2
+ dist
3
+ .venv
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.5
2
+ Name: nanoping-api
3
+ Version: 11.1.0.dev0
4
+ Summary: Typed client for the NanoPing REST API
5
+ Project-URL: Homepage, https://nanoping.com
6
+ Project-URL: Documentation, https://docs.nanoping.com/api/reference
7
+ Project-URL: Source, https://github.com/nanoping-labs/nanoping-api-python
8
+ License-Expression: LicenseRef-Proprietary
9
+ Classifier: Framework :: AsyncIO
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: websockets>=14
15
+ Description-Content-Type: text/markdown
16
+
17
+ # NanoPing API for Python
18
+
19
+ A typed client for the REST API of a [NanoPing](https://nanoping.com) node. It
20
+ covers every method of the API, with helpers for flows that take more than one
21
+ call, such as joining a hub. It has a blocking and an asyncio client, and needs
22
+ Python 3.11 or later.
23
+
24
+ This repository is generated from the NanoPing sources on every release, and
25
+ its version matches the NanoPing release it was made from. Changes made here
26
+ are overwritten by the next release.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install nanoping-api
32
+ ```
33
+
34
+ ## Use
35
+
36
+ ```python
37
+ from nanoping_api import NanopingClient
38
+
39
+ client = NanopingClient("http://127.0.0.1:10565")
40
+
41
+ local = client.hub_client.get_local_node_id()
42
+ for update in client.hub_client.stream_connection_state():
43
+ print(update.state)
44
+ ```
45
+
46
+ `AsyncNanopingClient` has the same methods for asyncio, and its streams are
47
+ read with `async for`. Requests and responses are dataclasses, and the fields of
48
+ a request are passed as keyword arguments. A failed call raises an `ApiError`,
49
+ whose `code` says why, such as `Code.NOT_FOUND`.
50
+
51
+ ## Examples
52
+
53
+ `examples` holds the program the
54
+ [HTTP REST guide](https://docs.nanoping.com/api/http-rest) is made from. It
55
+ joins a node to a hub and manages pipelines on both. Start the two nodes as the
56
+ comment in `examples/main.py` describes, then run
57
+
58
+ ```bash
59
+ pip install nanoping-api
60
+ python examples/main.py --client http://127.0.0.1:10565 --server http://127.0.0.1:10566
61
+ ```
62
+
63
+ The API is documented at https://docs.nanoping.com/api/reference.
@@ -0,0 +1,47 @@
1
+ # NanoPing API for Python
2
+
3
+ A typed client for the REST API of a [NanoPing](https://nanoping.com) node. It
4
+ covers every method of the API, with helpers for flows that take more than one
5
+ call, such as joining a hub. It has a blocking and an asyncio client, and needs
6
+ Python 3.11 or later.
7
+
8
+ This repository is generated from the NanoPing sources on every release, and
9
+ its version matches the NanoPing release it was made from. Changes made here
10
+ are overwritten by the next release.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install nanoping-api
16
+ ```
17
+
18
+ ## Use
19
+
20
+ ```python
21
+ from nanoping_api import NanopingClient
22
+
23
+ client = NanopingClient("http://127.0.0.1:10565")
24
+
25
+ local = client.hub_client.get_local_node_id()
26
+ for update in client.hub_client.stream_connection_state():
27
+ print(update.state)
28
+ ```
29
+
30
+ `AsyncNanopingClient` has the same methods for asyncio, and its streams are
31
+ read with `async for`. Requests and responses are dataclasses, and the fields of
32
+ a request are passed as keyword arguments. A failed call raises an `ApiError`,
33
+ whose `code` says why, such as `Code.NOT_FOUND`.
34
+
35
+ ## Examples
36
+
37
+ `examples` holds the program the
38
+ [HTTP REST guide](https://docs.nanoping.com/api/http-rest) is made from. It
39
+ joins a node to a hub and manages pipelines on both. Start the two nodes as the
40
+ comment in `examples/main.py` describes, then run
41
+
42
+ ```bash
43
+ pip install nanoping-api
44
+ python examples/main.py --client http://127.0.0.1:10565 --server http://127.0.0.1:10566
45
+ ```
46
+
47
+ The API is documented at https://docs.nanoping.com/api/reference.
@@ -0,0 +1,194 @@
1
+ """The code of the HTTP REST guide in the NanoPing docs. Each step of the guide is
2
+ one function, and run runs them in order."""
3
+
4
+ import asyncio
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ from nanoping_api import (
9
+ ApiError,
10
+ AsyncNanopingClient,
11
+ AsyncServerStream,
12
+ Code,
13
+ HubNode,
14
+ Level,
15
+ NodeMetadata,
16
+ NodeMetadataItem,
17
+ NodeService,
18
+ RestartPolicy,
19
+ RestartPolicyOnFailure,
20
+ StreamPipelinesEvent,
21
+ )
22
+ from pipeline_config import PIPELINE_CONFIG
23
+
24
+ Output = Callable[[str], None]
25
+
26
+
27
+ @dataclass
28
+ class Addresses:
29
+ """Addresses of the two nodes the guide uses: a client node that joins the hub,
30
+ and the node hosting the hub server."""
31
+
32
+ client: str
33
+ """REST API of the client node, e.g. "http://127.0.0.1:10565"."""
34
+ server: str
35
+ """REST API of the hub server node, e.g. "http://127.0.0.1:10566"."""
36
+ server_http: str
37
+ """HTTP address of the hub server node, e.g. "http://127.0.0.1:8769"."""
38
+
39
+
40
+ async def set_node_information(client: AsyncNanopingClient, out: Output) -> None:
41
+ """Names the client node and gives it metadata that other nodes on the hub can
42
+ filter on."""
43
+ local = await client.hub_client.get_local_node_id()
44
+
45
+ response = await client.hub_client.set_node(
46
+ node_id=local.node_id,
47
+ name="My NanoPing Client",
48
+ metadata=NodeMetadata(
49
+ items={
50
+ "type": NodeMetadataItem(string_value="my-type"),
51
+ "location": NodeMetadataItem(string_value="Denmark, Aalborg"),
52
+ }
53
+ ),
54
+ )
55
+
56
+ out(f'Named node {response.node.id} "{response.node.name}"')
57
+
58
+
59
+ async def watch_connection_state(client: AsyncNanopingClient, out: Output) -> None:
60
+ """Prints the connection state of the client node every time it changes, until
61
+ the task is cancelled."""
62
+ async for update in client.hub_client.stream_connection_state():
63
+ out(f"Connection state: {update.state}")
64
+
65
+
66
+ async def accept_join_requests(server: AsyncNanopingClient) -> None:
67
+ """Accepts every node that asks to join the hub server, until the task is
68
+ cancelled."""
69
+ await server.answer_join_requests(lambda request: True)
70
+
71
+
72
+ async def join_the_hub(client: AsyncNanopingClient, server_http_address: str, out: Output) -> None:
73
+ """Joins the client node to the hub server. The call waits until the hub server
74
+ accepted or rejected the request. A node that already is part of the hub stays
75
+ so."""
76
+ try:
77
+ await client.join_hub(server_http_address)
78
+ out("Joined the hub")
79
+ except ApiError as error:
80
+ if error.code == Code.ALREADY_EXISTS:
81
+ out("Already part of the hub")
82
+ return
83
+ if error.code == Code.UNAUTHENTICATED:
84
+ raise RuntimeError("The hub server rejected the node") from error
85
+ raise
86
+
87
+
88
+ async def print_nodes(client: AsyncNanopingClient, out: Output) -> None:
89
+ """Prints the nodes on the hub: all of them, the ones with metadata "type" set
90
+ to "my-type", and the ones running pipelines."""
91
+ everything = await client.hub_server.get_nodes()
92
+ out("All nodes:")
93
+ for node in everything.nodes:
94
+ out(f" {node.name}")
95
+
96
+ my_type = await client.hub_server.get_nodes(
97
+ metadata_filters={"type": NodeMetadataItem(string_value="my-type")},
98
+ )
99
+ out("Nodes with type my-type:")
100
+ for node in my_type.nodes:
101
+ out(f" {node.name}")
102
+
103
+ out("Nodes running pipelines:")
104
+ for node in await pipeline_nodes(client):
105
+ out(f" {node.name}")
106
+
107
+
108
+ async def pipeline_nodes(client: AsyncNanopingClient) -> list[HubNode]:
109
+ """Returns the nodes on the hub that run pipelines."""
110
+ response = await client.hub_server.get_nodes(service_filters=[NodeService.PIPELINES])
111
+ return response.nodes
112
+
113
+
114
+ async def watch_pipelines(
115
+ client: AsyncNanopingClient, node: HubNode
116
+ ) -> AsyncServerStream[StreamPipelinesEvent]:
117
+ """Opens a stream of the changes to the pipelines on the node. It returns once
118
+ the stream is ready, so no change made after it returns is missed."""
119
+ return await client.pipelines.stream_pipelines(node_id=node.id)
120
+
121
+
122
+ async def print_pipeline_events(
123
+ events: AsyncServerStream[StreamPipelinesEvent], node: HubNode, out: Output
124
+ ) -> None:
125
+ """Prints an event every time a pipeline on the node is created, updated or
126
+ deleted, until the task is cancelled."""
127
+ async for event in events:
128
+ if event.create:
129
+ out(f' Event on {node.name}: created "{event.create.pipeline.name}"')
130
+ elif event.update:
131
+ out(f' Event on {node.name}: updated "{event.update.pipeline.name}"')
132
+ elif event.delete:
133
+ out(f' Event on {node.name}: deleted "{event.delete.pipeline.name}"')
134
+
135
+
136
+ async def manage_pipeline(client: AsyncNanopingClient, node_id: str, out: Output) -> None:
137
+ """Creates a pipeline on the node, renames it, reads it back and deletes it
138
+ again."""
139
+ created = await client.pipelines.create_pipeline(
140
+ node_id=node_id,
141
+ name="My First Pipeline",
142
+ json_config=PIPELINE_CONFIG,
143
+ restart_policy=RestartPolicy(on_failure=RestartPolicyOnFailure(max_restarts=3)),
144
+ default_logging_level=Level.DEBUG,
145
+ )
146
+ pipeline = created.pipeline
147
+ out(f' Created "{pipeline.name}"')
148
+
149
+ # An update replaces every setting, so send the current ones along with the
150
+ # new name.
151
+ updated = await client.pipelines.update_pipeline(
152
+ node_id=node_id,
153
+ id=pipeline.id,
154
+ name="My Renamed Pipeline",
155
+ json_config=pipeline.json_config,
156
+ restart_policy=pipeline.restart_policy,
157
+ startup_policy=pipeline.startup_policy,
158
+ default_logging_level=pipeline.default_logging_level,
159
+ instructions_timeout=pipeline.instructions_timeout,
160
+ )
161
+ out(f' Renamed to "{updated.pipeline.name}"')
162
+
163
+ fetched = await client.pipelines.get_pipeline_by_id(node_id=node_id, id=pipeline.id)
164
+ out(f' Read back "{fetched.pipeline.name}"')
165
+
166
+ await client.pipelines.delete_pipeline(node_id=node_id, id=pipeline.id)
167
+ out(f' Deleted "{fetched.pipeline.name}"')
168
+
169
+
170
+ async def run(addresses: Addresses, out: Output) -> None:
171
+ """Connects to both nodes and runs every step of the guide."""
172
+ async with (
173
+ AsyncNanopingClient(addresses.client) as client,
174
+ AsyncNanopingClient(addresses.server) as server,
175
+ ):
176
+ background: list[asyncio.Task[None]] = []
177
+ try:
178
+ background.append(asyncio.create_task(watch_connection_state(client, out)))
179
+ await set_node_information(client, out)
180
+
181
+ background.append(asyncio.create_task(accept_join_requests(server)))
182
+ await join_the_hub(client, addresses.server_http, out)
183
+
184
+ await print_nodes(client, out)
185
+
186
+ for node in await pipeline_nodes(client):
187
+ out(f"Managing a pipeline on {node.name}")
188
+ events = await watch_pipelines(client, node)
189
+ background.append(asyncio.create_task(print_pipeline_events(events, node, out)))
190
+ await manage_pipeline(client, node.id, out)
191
+ finally:
192
+ for task in background:
193
+ task.cancel()
194
+ await asyncio.gather(*background, return_exceptions=True)
@@ -0,0 +1,37 @@
1
+ # Runs the HTTP REST guide of the NanoPing docs against two nodes.
2
+ #
3
+ # Start the client node with
4
+ #
5
+ # np up --rest-api 127.0.0.1:10565
6
+ #
7
+ # and the hub server node with `np -c config.yaml up`, where config.yaml is
8
+ #
9
+ # node:
10
+ # name: My Server
11
+ # http:
12
+ # address: 127.0.0.1:8769
13
+ # hubServer:
14
+ # authenticationByRequest:
15
+ # timeout: 5m
16
+ # restApi:
17
+ # address: 127.0.0.1:10566
18
+ # pipelines: {}
19
+ #
20
+ # Then run `python main.py`.
21
+ import argparse
22
+ import asyncio
23
+ import sys
24
+
25
+ from guide import Addresses, run
26
+
27
+ parser = argparse.ArgumentParser()
28
+ parser.add_argument("--client", default="http://127.0.0.1:10565")
29
+ parser.add_argument("--server", default="http://127.0.0.1:10566")
30
+ parser.add_argument("--server-http", default="http://127.0.0.1:8769")
31
+ arguments = parser.parse_args()
32
+
33
+ try:
34
+ asyncio.run(run(Addresses(arguments.client, arguments.server, arguments.server_http), print))
35
+ except Exception as error:
36
+ print(error, file=sys.stderr)
37
+ sys.exit(1)
@@ -0,0 +1,23 @@
1
+ """A pipeline that sends traffic from a traffic source to a traffic sink on the
2
+ same node."""
3
+
4
+ PIPELINE_CONFIG = """{
5
+ "version": 13,
6
+ "plumr_config": {
7
+ "pipeline": {
8
+ "traffic_sink-1": {
9
+ "traffic_sink": {
10
+ "input": "[traffic_sink-1|in:0]-[uniform_traffic_source-1|out:0]",
11
+ "mtu": 1500
12
+ }
13
+ },
14
+ "uniform_traffic_source-1": {
15
+ "uniform_traffic_source": {
16
+ "output": "[traffic_sink-1|in:0]-[uniform_traffic_source-1|out:0]",
17
+ "total_packets": 5000,
18
+ "interval": 25
19
+ }
20
+ }
21
+ }
22
+ }
23
+ }"""
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nanoping-api"
7
+ version = "11.1.0.dev0"
8
+ description = "Typed client for the NanoPing REST API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "LicenseRef-Proprietary"
12
+ dependencies = [
13
+ "httpx>=0.27",
14
+ "websockets>=14",
15
+ ]
16
+ classifiers = [
17
+ "Framework :: AsyncIO",
18
+ "Programming Language :: Python :: 3",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://nanoping.com"
24
+ Documentation = "https://docs.nanoping.com/api/reference"
25
+ Source = "https://github.com/nanoping-labs/nanoping-api-python"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/nanoping_api"]
29
+
30
+ [tool.mypy]
31
+ strict = true
@@ -0,0 +1,18 @@
1
+ """A typed client for the REST API of a NanoPing node."""
2
+
3
+ from ._client import AsyncNanopingClient, NanopingClient
4
+ from ._generated import *
5
+ from ._generated import __all__ as _generated_all
6
+ from ._runtime import ApiError, AsyncBidiStream, AsyncServerStream, BidiStream, Code, ServerStream
7
+
8
+ __all__ = [
9
+ "ApiError",
10
+ "AsyncBidiStream",
11
+ "AsyncNanopingClient",
12
+ "AsyncServerStream",
13
+ "BidiStream",
14
+ "Code",
15
+ "NanopingClient",
16
+ "ServerStream",
17
+ *_generated_all,
18
+ ]
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
5
+ from typing import Self
6
+
7
+ import httpx
8
+
9
+ from ._generated import (
10
+ AuthenticationByRequestAnswer,
11
+ AuthenticationByRequestRequest,
12
+ Message,
13
+ Options,
14
+ _AsyncServices,
15
+ _Services,
16
+ )
17
+ from ._runtime import AsyncTransport, Transport
18
+
19
+ _EVERY_LINE = Options(lines=2**63 - 1)
20
+
21
+
22
+ class NanopingClient(_Services):
23
+ """A blocking client for the REST API of a node.
24
+
25
+ `base_url` is the address of the REST API, such as "http://127.0.0.1:10565",
26
+ and `headers` are sent with every request. `timeout` limits each call, in
27
+ seconds, but not the wait for the next message of a stream. By default a call
28
+ waits as long as it takes. Pass `http_client` to send the calls with an
29
+ httpx.Client of your own.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ base_url: str,
35
+ *,
36
+ headers: Mapping[str, str] | None = None,
37
+ timeout: float | None = None,
38
+ http_client: httpx.Client | None = None,
39
+ ) -> None:
40
+ self._transport = Transport(base_url, headers, timeout, http_client)
41
+ super().__init__(self._transport)
42
+
43
+ def close(self) -> None:
44
+ """Closes the connections of the client, unless they belong to `http_client`."""
45
+ self._transport.close()
46
+
47
+ def __enter__(self) -> Self:
48
+ return self
49
+
50
+ def __exit__(self, *_: object) -> None:
51
+ self.close()
52
+
53
+ def join_hub(self, hub_server_address: str) -> None:
54
+ """Joins the hub server at `hub_server_address` with the node hosting the API.
55
+
56
+ Returns once the hub server approved the node. Raises an ApiError with
57
+ Code.UNAUTHENTICATED when the request was rejected, Code.DEADLINE_EXCEEDED
58
+ when nobody answered in time and Code.ALREADY_EXISTS when the node already
59
+ is part of a hub.
60
+ """
61
+ self.hub_client.authenticate_by_request(hub_server_address=hub_server_address)
62
+
63
+ def answer_join_requests(self, decide: Callable[[AuthenticationByRequestRequest], bool]) -> None:
64
+ """Answers the nodes that ask to join the hub server hosted by the node hosting
65
+ the API.
66
+
67
+ `decide` is called for every request and returns whether the node may join.
68
+ Runs until the stream ends or fails.
69
+ """
70
+ with self.hub_server.authentication_by_requests() as stream:
71
+ for request in stream:
72
+ stream.send(AuthenticationByRequestAnswer(request_id=request.request_id, accept=decide(request)))
73
+
74
+ def run_pipeline(self, node_id: str, pipeline_id: str) -> Iterator[Message]:
75
+ """Starts a pipeline and follows the log of the new run from its first line.
76
+
77
+ The pipeline starts when the loop does. Yields each log message until the
78
+ log stream ends or the loop stops, which closes the stream.
79
+ """
80
+ run = self.pipelines.start_pipeline(node_id=node_id, id=pipeline_id)
81
+ with self.pipelines.open_log_stream(node_id=node_id, run_id=run.run_id, options=_EVERY_LINE) as stream:
82
+ for batch in stream:
83
+ yield from batch.messages
84
+
85
+
86
+ class AsyncNanopingClient(_AsyncServices):
87
+ """An asyncio client for the REST API of a node.
88
+
89
+ `base_url` is the address of the REST API, such as "http://127.0.0.1:10565",
90
+ and `headers` are sent with every request. `timeout` limits each call, in
91
+ seconds, but not the wait for the next message of a stream. By default a call
92
+ waits as long as it takes. Pass `http_client` to send the calls with an
93
+ httpx.AsyncClient of your own.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ base_url: str,
99
+ *,
100
+ headers: Mapping[str, str] | None = None,
101
+ timeout: float | None = None,
102
+ http_client: httpx.AsyncClient | None = None,
103
+ ) -> None:
104
+ self._transport = AsyncTransport(base_url, headers, timeout, http_client)
105
+ super().__init__(self._transport)
106
+
107
+ async def aclose(self) -> None:
108
+ """Closes the connections of the client, unless they belong to `http_client`."""
109
+ await self._transport.aclose()
110
+
111
+ async def __aenter__(self) -> Self:
112
+ return self
113
+
114
+ async def __aexit__(self, *_: object) -> None:
115
+ await self.aclose()
116
+
117
+ async def join_hub(self, hub_server_address: str) -> None:
118
+ """Joins the hub server at `hub_server_address` with the node hosting the API.
119
+
120
+ Returns once the hub server approved the node. Raises an ApiError with
121
+ Code.UNAUTHENTICATED when the request was rejected, Code.DEADLINE_EXCEEDED
122
+ when nobody answered in time and Code.ALREADY_EXISTS when the node already
123
+ is part of a hub.
124
+ """
125
+ await self.hub_client.authenticate_by_request(hub_server_address=hub_server_address)
126
+
127
+ async def answer_join_requests(
128
+ self, decide: Callable[[AuthenticationByRequestRequest], bool | Awaitable[bool]]
129
+ ) -> None:
130
+ """Answers the nodes that ask to join the hub server hosted by the node hosting
131
+ the API.
132
+
133
+ `decide` is called for every request and returns, or resolves to, whether
134
+ the node may join. Runs until the stream ends or fails, or the task is
135
+ cancelled.
136
+ """
137
+ async with self.hub_server.authentication_by_requests() as stream:
138
+ async for request in stream:
139
+ accept = decide(request)
140
+ if inspect.isawaitable(accept):
141
+ accept = await accept
142
+ await stream.send(AuthenticationByRequestAnswer(request_id=request.request_id, accept=accept))
143
+
144
+ async def run_pipeline(self, node_id: str, pipeline_id: str) -> AsyncIterator[Message]:
145
+ """Starts a pipeline and follows the log of the new run from its first line.
146
+
147
+ The pipeline starts when the loop does. Yields each log message until the
148
+ log stream ends or the loop stops, which closes the stream.
149
+ """
150
+ run = await self.pipelines.start_pipeline(node_id=node_id, id=pipeline_id)
151
+ async with self.pipelines.open_log_stream(node_id=node_id, run_id=run.run_id, options=_EVERY_LINE) as stream:
152
+ async for batch in stream:
153
+ for message in batch.messages:
154
+ yield message