natsio-natscontext 0.1.0__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,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: natsio-natscontext
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Load `nats` CLI context files (ADR-21) into natsio connect options
|
|
5
|
+
Keywords: nats,nats.io,natscontext,nats-cli,configuration
|
|
6
|
+
Author: Corrupt Mane
|
|
7
|
+
Author-email: Corrupt Mane <corruptmane@gmail.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: natsio
|
|
16
|
+
Requires-Python: >=3.13
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# natsio-natscontext
|
|
20
|
+
|
|
21
|
+
Connect [natsio](https://pypi.org/project/natsio/) using the connection
|
|
22
|
+
profiles ("contexts") managed by the [`nats` command-line
|
|
23
|
+
tool](https://github.com/nats-io/natscli) — the same JSON files under
|
|
24
|
+
`~/.config/nats/context/`. This is the Python counterpart of the Go helper
|
|
25
|
+
[`orbit.go/natscontext`](https://github.com/synadia-io/orbit.go/tree/main/natscontext),
|
|
26
|
+
following [ADR-21](https://github.com/nats-io/nats-architecture-and-design).
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install natsio-natscontext
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
Create a context with the CLI, then use it from Python:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
nats context add staging --server nats://staging:4222 --creds ~/staging.creds
|
|
38
|
+
nats context select staging
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import natsio.natscontext as natscontext
|
|
43
|
+
|
|
44
|
+
# Connect using a named context...
|
|
45
|
+
async with await natscontext.connect("staging") as nc:
|
|
46
|
+
await nc.publish("greet", b"hi")
|
|
47
|
+
|
|
48
|
+
# ...or the currently selected context (from `nats context select`).
|
|
49
|
+
async with await natscontext.connect() as nc:
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
# Overrides are forwarded to natsio.connect and win over the context:
|
|
53
|
+
nc = await natscontext.connect("staging", name="my-app", ping_interval=30)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Inspect or reuse a context without connecting:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
ctx = natscontext.load("staging") # -> Context
|
|
60
|
+
print(ctx.url, ctx.jetstream_domain)
|
|
61
|
+
kwargs = ctx.connect_kwargs() # dict of natsio.connect(**kwargs)
|
|
62
|
+
|
|
63
|
+
natscontext.list_contexts() # ['prod', 'staging', ...]
|
|
64
|
+
natscontext.selected_context() # 'staging' or None
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
An absolute path is loaded directly instead of by name:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
ctx = natscontext.load("/etc/nats/prod.json")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Discovery
|
|
74
|
+
|
|
75
|
+
Contexts are resolved under `$XDG_CONFIG_HOME/nats` (falling back to
|
|
76
|
+
`~/.config/nats`):
|
|
77
|
+
|
|
78
|
+
| Path | Purpose |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `nats/context/<name>.json` | one context per file |
|
|
81
|
+
| `nats/context.txt` | name of the active/selected context |
|
|
82
|
+
|
|
83
|
+
## Field mapping
|
|
84
|
+
|
|
85
|
+
| Context field | natsio option | Notes |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `url` | `servers` | split on `,` into a tuple |
|
|
88
|
+
| `user` + `password` | `user`, `password` | |
|
|
89
|
+
| `creds` | `credentials` | `~` and `$VAR` expanded |
|
|
90
|
+
| `nkey` | `nkey_seed` | the **file** is read and the seed extracted |
|
|
91
|
+
| `token` | `token` | |
|
|
92
|
+
| `cert` + `key` | `tls` | `SSLContext.load_cert_chain` |
|
|
93
|
+
| `ca` | `tls` | `SSLContext.load_verify_locations` |
|
|
94
|
+
| `tls_first` | `tls.handshake_first` | |
|
|
95
|
+
| `inbox_prefix` | `inbox_prefix` | |
|
|
96
|
+
|
|
97
|
+
**Auth precedence** mirrors the Go helper: `user` › `creds` › `nkey` › `token`.
|
|
98
|
+
natsio does not allow combining mechanisms, so a `token` set *alongside*
|
|
99
|
+
another mechanism is ignored (nats.go would send both).
|
|
100
|
+
|
|
101
|
+
### Parsed but not applied
|
|
102
|
+
|
|
103
|
+
These fields are read and preserved on the returned `Context` (so you can use
|
|
104
|
+
them yourself — e.g. the JetStream domain when creating a JetStream context),
|
|
105
|
+
but natsio's connection layer has no equivalent and does **not** apply them:
|
|
106
|
+
|
|
107
|
+
| Context field | `Context` attribute | Why not applied |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `jetstream_domain` | `.jetstream_domain` | connection-independent; used at the JetStream API layer |
|
|
110
|
+
| `jetstream_api_prefix` | `.jetstream_api_prefix` | JetStream API layer |
|
|
111
|
+
| `jetstream_event_prefix` | `.jetstream_event_prefix` | JetStream API layer |
|
|
112
|
+
| `socks_proxy` | `.socks_proxy` | natsio has no custom-dialer / proxy hook |
|
|
113
|
+
| `nsc` | `.nsc` | would shell out to the `nsc` binary |
|
|
114
|
+
| `user_jwt` | `.user_jwt` | the Go helper parses but never applies it either |
|
|
115
|
+
| `color_scheme` | `.color_scheme` | CLI cosmetic |
|
|
116
|
+
| `description` | `.description` | metadata |
|
|
117
|
+
| `windows_cert_store*` | `.windows_cert_store` | unsupported — `connect_kwargs()` raises `ContextError` |
|
|
118
|
+
|
|
119
|
+
Unrecognised JSON keys are kept in `Context.raw` and reported by
|
|
120
|
+
`Context.unknown_keys`.
|
|
121
|
+
|
|
122
|
+
## Interop with the `nats` CLI
|
|
123
|
+
|
|
124
|
+
This package only ever **reads** context files; it never writes them. Manage
|
|
125
|
+
contexts with the CLI (`nats context add|select|edit|ls|rm`). Files written by
|
|
126
|
+
this library's format are byte-compatible with what the CLI expects.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
Apache-2.0.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# natsio-natscontext
|
|
2
|
+
|
|
3
|
+
Connect [natsio](https://pypi.org/project/natsio/) using the connection
|
|
4
|
+
profiles ("contexts") managed by the [`nats` command-line
|
|
5
|
+
tool](https://github.com/nats-io/natscli) — the same JSON files under
|
|
6
|
+
`~/.config/nats/context/`. This is the Python counterpart of the Go helper
|
|
7
|
+
[`orbit.go/natscontext`](https://github.com/synadia-io/orbit.go/tree/main/natscontext),
|
|
8
|
+
following [ADR-21](https://github.com/nats-io/nats-architecture-and-design).
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install natsio-natscontext
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
Create a context with the CLI, then use it from Python:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
nats context add staging --server nats://staging:4222 --creds ~/staging.creds
|
|
20
|
+
nats context select staging
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import natsio.natscontext as natscontext
|
|
25
|
+
|
|
26
|
+
# Connect using a named context...
|
|
27
|
+
async with await natscontext.connect("staging") as nc:
|
|
28
|
+
await nc.publish("greet", b"hi")
|
|
29
|
+
|
|
30
|
+
# ...or the currently selected context (from `nats context select`).
|
|
31
|
+
async with await natscontext.connect() as nc:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
# Overrides are forwarded to natsio.connect and win over the context:
|
|
35
|
+
nc = await natscontext.connect("staging", name="my-app", ping_interval=30)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Inspect or reuse a context without connecting:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
ctx = natscontext.load("staging") # -> Context
|
|
42
|
+
print(ctx.url, ctx.jetstream_domain)
|
|
43
|
+
kwargs = ctx.connect_kwargs() # dict of natsio.connect(**kwargs)
|
|
44
|
+
|
|
45
|
+
natscontext.list_contexts() # ['prod', 'staging', ...]
|
|
46
|
+
natscontext.selected_context() # 'staging' or None
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
An absolute path is loaded directly instead of by name:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
ctx = natscontext.load("/etc/nats/prod.json")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Discovery
|
|
56
|
+
|
|
57
|
+
Contexts are resolved under `$XDG_CONFIG_HOME/nats` (falling back to
|
|
58
|
+
`~/.config/nats`):
|
|
59
|
+
|
|
60
|
+
| Path | Purpose |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `nats/context/<name>.json` | one context per file |
|
|
63
|
+
| `nats/context.txt` | name of the active/selected context |
|
|
64
|
+
|
|
65
|
+
## Field mapping
|
|
66
|
+
|
|
67
|
+
| Context field | natsio option | Notes |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `url` | `servers` | split on `,` into a tuple |
|
|
70
|
+
| `user` + `password` | `user`, `password` | |
|
|
71
|
+
| `creds` | `credentials` | `~` and `$VAR` expanded |
|
|
72
|
+
| `nkey` | `nkey_seed` | the **file** is read and the seed extracted |
|
|
73
|
+
| `token` | `token` | |
|
|
74
|
+
| `cert` + `key` | `tls` | `SSLContext.load_cert_chain` |
|
|
75
|
+
| `ca` | `tls` | `SSLContext.load_verify_locations` |
|
|
76
|
+
| `tls_first` | `tls.handshake_first` | |
|
|
77
|
+
| `inbox_prefix` | `inbox_prefix` | |
|
|
78
|
+
|
|
79
|
+
**Auth precedence** mirrors the Go helper: `user` › `creds` › `nkey` › `token`.
|
|
80
|
+
natsio does not allow combining mechanisms, so a `token` set *alongside*
|
|
81
|
+
another mechanism is ignored (nats.go would send both).
|
|
82
|
+
|
|
83
|
+
### Parsed but not applied
|
|
84
|
+
|
|
85
|
+
These fields are read and preserved on the returned `Context` (so you can use
|
|
86
|
+
them yourself — e.g. the JetStream domain when creating a JetStream context),
|
|
87
|
+
but natsio's connection layer has no equivalent and does **not** apply them:
|
|
88
|
+
|
|
89
|
+
| Context field | `Context` attribute | Why not applied |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `jetstream_domain` | `.jetstream_domain` | connection-independent; used at the JetStream API layer |
|
|
92
|
+
| `jetstream_api_prefix` | `.jetstream_api_prefix` | JetStream API layer |
|
|
93
|
+
| `jetstream_event_prefix` | `.jetstream_event_prefix` | JetStream API layer |
|
|
94
|
+
| `socks_proxy` | `.socks_proxy` | natsio has no custom-dialer / proxy hook |
|
|
95
|
+
| `nsc` | `.nsc` | would shell out to the `nsc` binary |
|
|
96
|
+
| `user_jwt` | `.user_jwt` | the Go helper parses but never applies it either |
|
|
97
|
+
| `color_scheme` | `.color_scheme` | CLI cosmetic |
|
|
98
|
+
| `description` | `.description` | metadata |
|
|
99
|
+
| `windows_cert_store*` | `.windows_cert_store` | unsupported — `connect_kwargs()` raises `ContextError` |
|
|
100
|
+
|
|
101
|
+
Unrecognised JSON keys are kept in `Context.raw` and reported by
|
|
102
|
+
`Context.unknown_keys`.
|
|
103
|
+
|
|
104
|
+
## Interop with the `nats` CLI
|
|
105
|
+
|
|
106
|
+
This package only ever **reads** context files; it never writes them. Manage
|
|
107
|
+
contexts with the CLI (`nats context add|select|edit|ls|rm`). Files written by
|
|
108
|
+
this library's format are byte-compatible with what the CLI expects.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
Apache-2.0.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.10,<0.11"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "natsio-natscontext"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Load `nats` CLI context files (ADR-21) into natsio connect options"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
authors = [{ name = "Corrupt Mane", email = "corruptmane@gmail.com" }]
|
|
13
|
+
keywords = ["nats", "nats.io", "natscontext", "nats-cli", "configuration"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["natsio"]
|
|
23
|
+
|
|
24
|
+
[tool.uv.sources]
|
|
25
|
+
natsio = { workspace = true }
|
|
26
|
+
|
|
27
|
+
[tool.uv.build-backend]
|
|
28
|
+
module-name = "natsio.natscontext"
|
|
29
|
+
namespace = true
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""natsio.natscontext — load ``nats`` CLI context files (ADR-21) into natsio.
|
|
2
|
+
|
|
3
|
+
The `nats` command-line tool stores named connection profiles as JSON under
|
|
4
|
+
``$XDG_CONFIG_HOME/nats/context/<name>.json`` (falling back to
|
|
5
|
+
``~/.config/nats``), with the active profile named in a sibling
|
|
6
|
+
``context.txt``. This extension discovers those files and maps their fields
|
|
7
|
+
onto `natsio.connect` options.
|
|
8
|
+
|
|
9
|
+
import natsio.natscontext as natscontext
|
|
10
|
+
|
|
11
|
+
ctx = natscontext.load("staging") # or load() for the selected one
|
|
12
|
+
async with await natscontext.connect("staging") as nc:
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
This mirrors the semantics of the official Go helper
|
|
16
|
+
``github.com/synadia-io/orbit.go/natscontext``. Fields the `nats` CLI
|
|
17
|
+
understands but that a natsio connection cannot express (SOCKS proxy,
|
|
18
|
+
JetStream domain/prefixes, ``nsc`` lookups, CLI-only cosmetics) are still
|
|
19
|
+
parsed and preserved on the returned `Context` — see each field's docstring
|
|
20
|
+
and the ``README`` for what is and isn't applied.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import natsio
|
|
30
|
+
from natsio.client import Client
|
|
31
|
+
from natsio.errors import NATSError
|
|
32
|
+
from natsio.options import ConnectOptions, TLSConfig
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Context",
|
|
36
|
+
"ContextError",
|
|
37
|
+
"ContextMalformedError",
|
|
38
|
+
"ContextNotFoundError",
|
|
39
|
+
"config_dir",
|
|
40
|
+
"connect",
|
|
41
|
+
"context_dir",
|
|
42
|
+
"list_contexts",
|
|
43
|
+
"load",
|
|
44
|
+
"selected_context",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
# Errors
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
class ContextError(NATSError):
|
|
52
|
+
"""A NATS CLI context could not be resolved, read, or applied."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ContextNotFoundError(ContextError, FileNotFoundError):
|
|
56
|
+
"""The named context (or the active selection) does not exist."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ContextMalformedError(ContextError):
|
|
60
|
+
"""A context file exists but is not valid JSON / not a JSON object."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --------------------------------------------------------------------------- #
|
|
64
|
+
# Discovery (XDG layout, mirroring orbit.go's parentDir/ctxDir/selectedContext)
|
|
65
|
+
# --------------------------------------------------------------------------- #
|
|
66
|
+
def config_dir() -> Path:
|
|
67
|
+
"""The ``nats`` config directory: ``$XDG_CONFIG_HOME/nats`` or ``~/.config/nats``."""
|
|
68
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
69
|
+
parent = Path(xdg) if xdg else Path.home() / ".config"
|
|
70
|
+
return parent / "nats"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def context_dir() -> Path:
|
|
74
|
+
"""The directory holding ``<name>.json`` context files."""
|
|
75
|
+
return config_dir() / "context"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _selected_file() -> Path:
|
|
79
|
+
return config_dir() / "context.txt"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def selected_context() -> str | None:
|
|
83
|
+
"""The name of the active context (from ``context.txt``), or ``None``."""
|
|
84
|
+
try:
|
|
85
|
+
name = _selected_file().read_text(encoding="utf-8").strip()
|
|
86
|
+
except (FileNotFoundError, NotADirectoryError, OSError):
|
|
87
|
+
return None
|
|
88
|
+
return name or None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_contexts() -> list[str]:
|
|
92
|
+
"""Sorted names of all known contexts (``<name>.json`` files); ``[]`` if none."""
|
|
93
|
+
try:
|
|
94
|
+
entries = os.listdir(context_dir())
|
|
95
|
+
except (FileNotFoundError, NotADirectoryError, OSError):
|
|
96
|
+
return []
|
|
97
|
+
return sorted(name[: -len(".json")] for name in entries if name.endswith(".json"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _valid_name(name: str) -> bool:
|
|
101
|
+
# orbit.go validName: non-empty, no traversal, no path separators.
|
|
102
|
+
return bool(name) and ".." not in name and os.sep not in name and (os.altsep or os.sep) not in name
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
# Path expansion (orbit.go applies ~ and, for creds, $ENV expansion)
|
|
107
|
+
# --------------------------------------------------------------------------- #
|
|
108
|
+
def _expand(path: str) -> str:
|
|
109
|
+
"""Expand ``~`` and ``$VAR`` in a filesystem path, as the CLI does."""
|
|
110
|
+
return os.path.expanduser(os.path.expandvars(path)) if path else path
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------- #
|
|
114
|
+
# Context
|
|
115
|
+
# --------------------------------------------------------------------------- #
|
|
116
|
+
# Every JSON key orbit.go's Settings struct understands, so an unknown key can
|
|
117
|
+
# be flagged instead of silently dropped.
|
|
118
|
+
_KNOWN_KEYS = frozenset(
|
|
119
|
+
{
|
|
120
|
+
"name",
|
|
121
|
+
"description",
|
|
122
|
+
"url",
|
|
123
|
+
"socks_proxy",
|
|
124
|
+
"token",
|
|
125
|
+
"user",
|
|
126
|
+
"password",
|
|
127
|
+
"creds",
|
|
128
|
+
"nkey",
|
|
129
|
+
"cert",
|
|
130
|
+
"key",
|
|
131
|
+
"ca",
|
|
132
|
+
"nsc",
|
|
133
|
+
"jetstream_domain",
|
|
134
|
+
"jetstream_api_prefix",
|
|
135
|
+
"jetstream_event_prefix",
|
|
136
|
+
"inbox_prefix",
|
|
137
|
+
"user_jwt",
|
|
138
|
+
"color_scheme",
|
|
139
|
+
"tls_first",
|
|
140
|
+
"windows_cert_store",
|
|
141
|
+
"windows_cert_match_by",
|
|
142
|
+
"windows_cert_match",
|
|
143
|
+
"windows_ca_certs_match",
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class Context:
|
|
150
|
+
"""A parsed NATS CLI context.
|
|
151
|
+
|
|
152
|
+
Holds every field the ``nats`` CLI understands. `connect_kwargs` projects
|
|
153
|
+
the *applicable* subset onto `natsio.connect` keyword arguments; the rest
|
|
154
|
+
are preserved here for callers that need them (notably JetStream
|
|
155
|
+
domain/prefixes, which natsio's connection layer does not carry).
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
#: Friendly context name (file stem, or the ``name`` JSON field).
|
|
159
|
+
name: str
|
|
160
|
+
#: Path the context was loaded from, if any.
|
|
161
|
+
path: str | None = None
|
|
162
|
+
|
|
163
|
+
# -- applied to the natsio connection --
|
|
164
|
+
url: str = ""
|
|
165
|
+
user: str = ""
|
|
166
|
+
password: str = field(default="", repr=False)
|
|
167
|
+
token: str = field(default="", repr=False)
|
|
168
|
+
creds: str = ""
|
|
169
|
+
nkey: str = ""
|
|
170
|
+
cert: str = ""
|
|
171
|
+
key: str = ""
|
|
172
|
+
ca: str = ""
|
|
173
|
+
tls_first: bool = False
|
|
174
|
+
inbox_prefix: str = ""
|
|
175
|
+
|
|
176
|
+
# -- parsed & preserved, but NOT applied to the connection (see README) --
|
|
177
|
+
description: str = ""
|
|
178
|
+
socks_proxy: str = ""
|
|
179
|
+
nsc: str = ""
|
|
180
|
+
user_jwt: str = field(default="", repr=False)
|
|
181
|
+
jetstream_domain: str = ""
|
|
182
|
+
jetstream_api_prefix: str = ""
|
|
183
|
+
jetstream_event_prefix: str = ""
|
|
184
|
+
color_scheme: str = ""
|
|
185
|
+
windows_cert_store: str = ""
|
|
186
|
+
|
|
187
|
+
#: The raw decoded JSON, verbatim (including any unknown keys).
|
|
188
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_dict(cls, data: dict[str, Any], *, name: str, path: str | None = None) -> "Context":
|
|
192
|
+
def s(key: str) -> str:
|
|
193
|
+
value = data.get(key, "")
|
|
194
|
+
return value if isinstance(value, str) else ""
|
|
195
|
+
|
|
196
|
+
return cls(
|
|
197
|
+
name=data.get("name") or name,
|
|
198
|
+
path=path,
|
|
199
|
+
url=s("url"),
|
|
200
|
+
user=s("user"),
|
|
201
|
+
password=s("password"),
|
|
202
|
+
token=s("token"),
|
|
203
|
+
creds=s("creds"),
|
|
204
|
+
nkey=s("nkey"),
|
|
205
|
+
cert=s("cert"),
|
|
206
|
+
key=s("key"),
|
|
207
|
+
ca=s("ca"),
|
|
208
|
+
tls_first=bool(data.get("tls_first", False)),
|
|
209
|
+
inbox_prefix=s("inbox_prefix"),
|
|
210
|
+
description=s("description"),
|
|
211
|
+
socks_proxy=s("socks_proxy"),
|
|
212
|
+
nsc=s("nsc"),
|
|
213
|
+
user_jwt=s("user_jwt"),
|
|
214
|
+
jetstream_domain=s("jetstream_domain"),
|
|
215
|
+
jetstream_api_prefix=s("jetstream_api_prefix"),
|
|
216
|
+
jetstream_event_prefix=s("jetstream_event_prefix"),
|
|
217
|
+
color_scheme=s("color_scheme"),
|
|
218
|
+
windows_cert_store=s("windows_cert_store"),
|
|
219
|
+
raw=data,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def unknown_keys(self) -> frozenset[str]:
|
|
224
|
+
"""JSON keys present in the file that this mapper does not recognise."""
|
|
225
|
+
return frozenset(self.raw) - _KNOWN_KEYS
|
|
226
|
+
|
|
227
|
+
def _build_tls(self) -> TLSConfig | None:
|
|
228
|
+
if not (self.cert or self.key or self.ca or self.tls_first):
|
|
229
|
+
return None
|
|
230
|
+
# orbit.go applies a client cert only when *both* cert and key are set;
|
|
231
|
+
# a half-configured pair is a config error rather than a silent drop.
|
|
232
|
+
if bool(self.cert) != bool(self.key):
|
|
233
|
+
raise ContextError("context sets only one of 'cert'/'key'; both are required for a client certificate")
|
|
234
|
+
# natsio's TLSConfig loads the PEM files itself (lazily, per reconnect).
|
|
235
|
+
return TLSConfig(
|
|
236
|
+
certfile=_expand(self.cert) or None,
|
|
237
|
+
keyfile=_expand(self.key) or None,
|
|
238
|
+
cafile=_expand(self.ca) or None,
|
|
239
|
+
handshake_first=self.tls_first,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def connect_kwargs(self) -> dict[str, Any]:
|
|
243
|
+
"""Project this context onto `natsio.connect` keyword arguments.
|
|
244
|
+
|
|
245
|
+
Raises `ContextError` for settings natsio cannot honour (a Windows
|
|
246
|
+
certificate store, or a half-specified client certificate).
|
|
247
|
+
"""
|
|
248
|
+
if self.windows_cert_store:
|
|
249
|
+
raise ContextError("windows certificate stores are not supported")
|
|
250
|
+
|
|
251
|
+
kwargs: dict[str, Any] = {}
|
|
252
|
+
|
|
253
|
+
if self.url:
|
|
254
|
+
# A context may hold a comma-separated server list (nats.Connect
|
|
255
|
+
# splits on ',' too).
|
|
256
|
+
kwargs["servers"] = tuple(part.strip() for part in self.url.split(",") if part.strip())
|
|
257
|
+
|
|
258
|
+
# Auth precedence mirrors orbit.go's switch (user > creds > nkey), with
|
|
259
|
+
# token as the final fallback. natsio forbids combining mechanisms, so —
|
|
260
|
+
# unlike nats.go, which would additionally send a token alongside these —
|
|
261
|
+
# a token set together with another mechanism is ignored (see README).
|
|
262
|
+
if self.user:
|
|
263
|
+
kwargs["user"] = self.user
|
|
264
|
+
kwargs["password"] = self.password
|
|
265
|
+
elif self.creds:
|
|
266
|
+
kwargs["credentials"] = _expand(self.creds)
|
|
267
|
+
elif self.nkey:
|
|
268
|
+
# natsio's NKeyFileAuth reads and parses the seed file itself,
|
|
269
|
+
# re-reading on every reconnect (rotation) instead of once at load.
|
|
270
|
+
kwargs["nkey_file"] = _expand(self.nkey)
|
|
271
|
+
elif self.token:
|
|
272
|
+
kwargs["token"] = self.token
|
|
273
|
+
|
|
274
|
+
tls = self._build_tls()
|
|
275
|
+
if tls is not None:
|
|
276
|
+
kwargs["tls"] = tls
|
|
277
|
+
|
|
278
|
+
if self.inbox_prefix:
|
|
279
|
+
kwargs["inbox_prefix"] = self.inbox_prefix
|
|
280
|
+
|
|
281
|
+
return kwargs
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# --------------------------------------------------------------------------- #
|
|
285
|
+
# Loading
|
|
286
|
+
# --------------------------------------------------------------------------- #
|
|
287
|
+
def _load_file(path: Path, *, name: str) -> Context:
|
|
288
|
+
try:
|
|
289
|
+
content = path.read_text(encoding="utf-8")
|
|
290
|
+
except FileNotFoundError as exc:
|
|
291
|
+
raise ContextNotFoundError(f"context file not found: {path}") from exc
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
raise ContextError(f"cannot read context file {path}: {exc}") from exc
|
|
294
|
+
try:
|
|
295
|
+
data = json.loads(content)
|
|
296
|
+
except json.JSONDecodeError as exc:
|
|
297
|
+
raise ContextMalformedError(f"context file {path} is not valid JSON: {exc}") from exc
|
|
298
|
+
if not isinstance(data, dict):
|
|
299
|
+
raise ContextMalformedError(f"context file {path} must contain a JSON object")
|
|
300
|
+
return Context.from_dict(data, name=name, path=str(path))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load(name: str | None = None) -> Context:
|
|
304
|
+
"""Load a context by ``name``, or the selected context when ``name`` is omitted.
|
|
305
|
+
|
|
306
|
+
If ``name`` is an absolute path to a JSON file, that file is loaded directly
|
|
307
|
+
(its stem becomes the context name). Otherwise ``name`` is resolved under
|
|
308
|
+
the XDG context directory; when omitted it falls back to the active
|
|
309
|
+
selection from ``context.txt``.
|
|
310
|
+
|
|
311
|
+
Raises `ContextNotFoundError` when no name/selection is available or the
|
|
312
|
+
named context does not exist, and `ContextMalformedError` for invalid JSON.
|
|
313
|
+
"""
|
|
314
|
+
if name and os.path.isabs(name):
|
|
315
|
+
path = Path(name)
|
|
316
|
+
return _load_file(path, name=path.stem)
|
|
317
|
+
|
|
318
|
+
resolved = name or selected_context()
|
|
319
|
+
if not resolved:
|
|
320
|
+
raise ContextNotFoundError(
|
|
321
|
+
"no context name given and no context is selected "
|
|
322
|
+
f"(set one with `nats context select`, or create {_selected_file()})"
|
|
323
|
+
)
|
|
324
|
+
if not _valid_name(resolved):
|
|
325
|
+
raise ContextNotFoundError(f"invalid context name: {resolved!r}")
|
|
326
|
+
|
|
327
|
+
path = context_dir() / f"{resolved}.json"
|
|
328
|
+
if not path.exists():
|
|
329
|
+
raise ContextNotFoundError(f"unknown context {resolved!r} (looked in {context_dir()})")
|
|
330
|
+
return _load_file(path, name=resolved)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# --------------------------------------------------------------------------- #
|
|
334
|
+
# Convenience connect
|
|
335
|
+
# --------------------------------------------------------------------------- #
|
|
336
|
+
async def connect(
|
|
337
|
+
name: str | None = None,
|
|
338
|
+
/,
|
|
339
|
+
*,
|
|
340
|
+
options: ConnectOptions | None = None,
|
|
341
|
+
error_cb: Any = None,
|
|
342
|
+
**overrides: Any,
|
|
343
|
+
) -> Client:
|
|
344
|
+
"""Load a context and connect with it.
|
|
345
|
+
|
|
346
|
+
The context selector is **positional-only** so it does not collide with
|
|
347
|
+
natsio's ``name`` connect kwarg (the client name). ``overrides`` (and an
|
|
348
|
+
``options=`` base) are forwarded to `natsio.connect` and take precedence
|
|
349
|
+
over the context-derived settings — e.g. ``await connect("staging",
|
|
350
|
+
name="my-app")`` connects with context ``staging`` and client name
|
|
351
|
+
``my-app``.
|
|
352
|
+
"""
|
|
353
|
+
ctx = load(name)
|
|
354
|
+
kwargs = ctx.connect_kwargs()
|
|
355
|
+
kwargs.update(overrides)
|
|
356
|
+
return await natsio.connect(options=options, error_cb=error_cb, **kwargs)
|
|
File without changes
|