nytt 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.
nytt-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.3
2
+ Name: nytt
3
+ Version: 0.1.0
4
+ Summary: Python wrappers for working with the nytt CLI
5
+ Author: Cody Casterline
6
+ Requires-Dist: base58~=2.1
7
+ Requires-Dist: msgpack~=1.2
8
+ Requires-Dist: pydantic~=2.13
9
+ Requires-Python: >=3.15
10
+ Project-URL: Repository, https://codeberg.org/nfnitloop/nytt
11
+ Project-URL: Documentation, https://nfnitloop.codeberg.page/nytt/python-nytt/
12
+ Description-Content-Type: text/markdown
13
+
14
+ `nytt`
15
+ =====
16
+
17
+ Nýtt is an offline-first social networking / news reader.
18
+
19
+ The main implementation is written in Rust, and can be found here:
20
+ <https://crates.io/crates/nytt>
21
+
22
+ This library provides a high-level python wrapper around the CLI
23
+ so you can easily automate interacting with it.
24
+
25
+ For example, you can write scripts to sync content to/from Mastodon or RSS.
26
+
nytt-0.1.0/README.md ADDED
@@ -0,0 +1,13 @@
1
+ `nytt`
2
+ =====
3
+
4
+ Nýtt is an offline-first social networking / news reader.
5
+
6
+ The main implementation is written in Rust, and can be found here:
7
+ <https://crates.io/crates/nytt>
8
+
9
+ This library provides a high-level python wrapper around the CLI
10
+ so you can easily automate interacting with it.
11
+
12
+ For example, you can write scripts to sync content to/from Mastodon or RSS.
13
+
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "nytt"
3
+ version = "0.1.0"
4
+ description = "Python wrappers for working with the nytt CLI"
5
+ readme = "README.md"
6
+ requires-python = ">=3.15"
7
+ dependencies = [
8
+ "base58~=2.1",
9
+ "msgpack~=1.2",
10
+ "pydantic~=2.13",
11
+ ]
12
+
13
+ [[project.authors]]
14
+ name = "Cody Casterline"
15
+
16
+ [project.urls]
17
+ Repository = "https://codeberg.org/nfnitloop/nytt"
18
+ Documentation = "https://nfnitloop.codeberg.page/nytt/python-nytt/"
19
+
20
+ [tool.uv]
21
+ exclude-newer = "2026-10-01T00:00:00Z"
22
+
23
+ [tool.ruff.lint]
24
+ extend-select = ["ANN"]
25
+
26
+ [tool.ruff.lint.flake8-annotations]
27
+ suppress-dummy-args = true
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.13.7,<0.14.0"]
31
+ build-backend = "uv_build"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pdoc>=16.0.0",
36
+ "ruff>=0.16.9",
37
+ ]
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "nytt"
3
+ version = "0.1.0"
4
+ description = "Python wrappers for working with the nytt CLI"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Cody Casterline" }
8
+ ]
9
+
10
+ requires-python = ">=3.15"
11
+ dependencies = [
12
+ "base58~=2.1",
13
+ "msgpack~=1.2",
14
+ "pydantic~=2.13",
15
+ ]
16
+
17
+ [project.urls]
18
+ Repository = "https://codeberg.org/nfnitloop/nytt"
19
+ Documentation = "https://nfnitloop.codeberg.page/nytt/python-nytt/"
20
+
21
+ [tool.uv]
22
+ exclude-newer = "2026-10-01T00:00:00Z"
23
+
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.13.7,<0.14.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pdoc>=16.0.0",
32
+ "ruff>=0.16.9",
33
+ ]
34
+
35
+ [tool.ruff.lint]
36
+ extend-select = [
37
+ "ANN" # Require function type annotations
38
+ ]
39
+
40
+ flake8-annotations.suppress-dummy-args = true
@@ -0,0 +1,14 @@
1
+ """
2
+ Nýtt is an offline-first social networking / news reader.
3
+
4
+ The main implementation is written in Rust, and can be found here:
5
+ <https://crates.io/crates/nytt>
6
+
7
+ This library provides a high-level python wrapper around the CLI
8
+ so you can easily automate interacting with it.
9
+
10
+ For example, you can write scripts to sync content to/from Mastodon or RSS.
11
+ """
12
+
13
+ from .cli import *
14
+ from .types import *
@@ -0,0 +1,14 @@
1
+ from pydantic import AfterValidator
2
+
3
+ # Fun fact! If this isn't defined *before* you use it, Pydantic silently ignores it!
4
+ # (Might be an issue w/ the lazy evaluation of type hints? Dunno.)
5
+ # Moving these to a lib makes sure to fix that.
6
+
7
+ def num_bytes(expected_num: int)-> AfterValidator:
8
+ """Check that a `bytes` has an exact length"""
9
+ def validator(value: bytes) -> bytes:
10
+ if len(value) != expected_num:
11
+ raise ValueError(f"Expected {expected_num} bytes but found {len(value)}")
12
+ return value
13
+
14
+ return AfterValidator(validator)
@@ -0,0 +1,124 @@
1
+ import subprocess
2
+ from collections.abc import Generator, Iterable
3
+ from contextlib import contextmanager
4
+ from datetime import datetime
5
+ from typing import IO
6
+
7
+ import msgpack
8
+
9
+ # Usually discourage wildcard imports, but it's useful to make sure __all__ is properly defined:
10
+ from .types import *
11
+
12
+ __all__ = [
13
+ "CLI"
14
+ ]
15
+
16
+ class CLI:
17
+ """
18
+ A wrapper around CLI commands.
19
+ """
20
+
21
+ def __init__(self, *, bin_name: str ="nytt") -> None:
22
+ self.__bin_name = bin_name
23
+
24
+ def post_list(self, *, newer_than: str|None = None, older_than: str|None = None, by: str|None = None, limit: int|None = None) -> Generator[str]:
25
+ """
26
+ nytt post list ...
27
+ """
28
+
29
+ cmd = [ self.__bin_name, "post", "list", ]
30
+ if newer_than:
31
+ cmd += ["--newer-than", newer_than]
32
+ if older_than:
33
+ cmd += ["--older-than", older_than]
34
+ if by:
35
+ cmd += ["--by", by]
36
+ if limit:
37
+ cmd += ["--limit", str(limit)]
38
+
39
+ proc = subprocess.Popen(cmd, encoding="utf-8", stdout=subprocess.PIPE, stdin=subprocess.DEVNULL)
40
+
41
+ out = proc.stdout
42
+ if out is None:
43
+ raise CliException("Program output was None.")
44
+
45
+ with(proc):
46
+ while True:
47
+ line = out.readline()
48
+ # Every line ends in \n
49
+ if line == "":
50
+ break
51
+
52
+ line = line.strip()
53
+ if line == "":
54
+ continue
55
+
56
+ yield line
57
+
58
+ done = proc.wait()
59
+ if done != 0:
60
+ raise CliException(f"Command {cmd} exit code: {done}")
61
+
62
+ def post_get(self, id: str) -> SignedEnvelope:
63
+ """Get the signed envelope metadata for a post."""
64
+
65
+ cmd = [self.__bin_name, "post", "get", id]
66
+ completed = subprocess.run(cmd, capture_output=True, check=True)
67
+ msg = msgpack.unpackb(completed.stdout)
68
+
69
+ sulid = Sulid(msg["sulid"])
70
+
71
+ return SignedEnvelope(
72
+ id = sulid,
73
+ signer = PubKey(msg["signer"]),
74
+ signature = Signature(msg["signature"]),
75
+ raw_envelope = msg["envelope"],
76
+ envelope = Envelope.from_msgpack(msg["envelope"]),
77
+ )
78
+
79
+ def post_from_files(self, *,
80
+ files: Iterable[str],
81
+ timestamp: datetime|None,
82
+ post_as: PubKey|str,
83
+ tz_offset: int|None, # minutes, negative=westward.
84
+ quiet: bool = True
85
+ ) -> None:
86
+ cmd = [
87
+ self.__bin_name, "post", "from-files",
88
+ *files,
89
+ "--as", str(post_as),
90
+ "--yes", # the CLI prompt isn't really appropriate for use from a general purpose script.
91
+ ]
92
+ if timestamp is not None:
93
+ cmd += ["--timestamp", timestamp.isoformat()]
94
+ if tz_offset is not None:
95
+ cmd += ["--tz-offset", str(tz_offset)]
96
+
97
+ stdout = subprocess.DEVNULL if quiet else None
98
+ subprocess.run(cmd, check=True, stdout=stdout)
99
+
100
+
101
+ @contextmanager
102
+ def content_open(self, hash: Blake3Hash) -> Generator[IO[bytes]]:
103
+ """Open the contents of the blake3hash as a stream"""
104
+ cmd = [self.__bin_name, "content-store", "get", str(hash)]
105
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
106
+ out = proc.stdout
107
+ if out is None:
108
+ raise CliException("Popen provided no stdout.")
109
+
110
+ with proc:
111
+ yield out
112
+
113
+ if proc.returncode is not None and proc.returncode != 0:
114
+ raise CliException(f"streaming {hash} had exit code {proc.returncode}")
115
+
116
+ def content_read_text(self, hash: Blake3Hash, *, encoding: str ="utf-8") -> str:
117
+ """Read a file from the content store into memory and decode it as text."""
118
+ with self.content_open(hash) as f:
119
+ buf = f.read()
120
+ return buf.decode(encoding)
121
+
122
+
123
+ class CliException(Exception):
124
+ pass
@@ -0,0 +1,40 @@
1
+ import tempfile
2
+ from collections.abc import Iterator
3
+ from contextlib import AbstractContextManager
4
+ from os import path
5
+ from typing import Self
6
+
7
+
8
+ class PostFiles(AbstractContextManager):
9
+ "Helper for collecting files to save in a post."
10
+ def __init__(self) -> None:
11
+ self._tmpdir = tempfile.TemporaryDirectory(prefix="nytt_post")
12
+ self.file_paths = []
13
+
14
+ def add_file(self, name: str, contents: bytes) -> None:
15
+ "Add a file from memory. Will be cleaned up on __exit__()"
16
+ new_file = path.join(self._tmpdir.name, name)
17
+ with open(new_file, "xb") as f:
18
+ f.write(contents)
19
+ self.file_paths.append(new_file)
20
+
21
+ def add_existing_file(self, file_path: str) -> None:
22
+ "Add an existing file. Will *not* be cleaned up on __exit__()"
23
+ self.file_paths.append(file_path)
24
+
25
+ def __enter__(self) -> Self:
26
+ return self
27
+
28
+ def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
29
+ self.file_paths = []
30
+ self._tmpdir.cleanup()
31
+
32
+ def __iter__(self) -> Iterator[str]:
33
+ "Iterate over file_paths"
34
+ if len(self.file_paths) == 0:
35
+ # At least one file is expected when creating a post
36
+ raise RuntimeError("No files have been added to PostFiles")
37
+
38
+ return iter(self.file_paths)
39
+
40
+
@@ -0,0 +1,232 @@
1
+ import base64
2
+ import tomllib
3
+ from dataclasses import field
4
+ from datetime import UTC, datetime
5
+ from math import floor
6
+ from pathlib import Path
7
+ from typing import Annotated, Any, ClassVar
8
+
9
+ import base58
10
+ import msgpack
11
+ from pydantic.dataclasses import dataclass
12
+
13
+ from ._validators import num_bytes
14
+
15
+ __all__ = [
16
+ "B58Bytes",
17
+ "Blake3Hash",
18
+ "Config",
19
+ "Envelope",
20
+ "Identity",
21
+ "PostFile",
22
+ "PubKey",
23
+ "Signature",
24
+ "SignedEnvelope",
25
+ "Sulid",
26
+ ]
27
+
28
+
29
+ @dataclass
30
+ class Config:
31
+ """
32
+ The data type of the config file.
33
+ """
34
+ identity: dict[str, Identity]
35
+
36
+ @staticmethod
37
+ def load(*, file: Path | None = None) -> Config:
38
+ if not file:
39
+ file = Path.home() / ".config" / "nytt" / "config.toml"
40
+
41
+ with file.open("rb") as f:
42
+ data = tomllib.load(f)
43
+
44
+ return Config(**data)
45
+
46
+
47
+
48
+ @dataclass
49
+ class Identity:
50
+ """
51
+ An identity is an alias to a public key.
52
+
53
+ Since identities are stored in your local config file, they are only available locally.
54
+ """
55
+
56
+ id: str
57
+ "Base58-encoded pubkey"
58
+
59
+
60
+ @dataclass(kw_only=True, slots=True)
61
+ class SignedEnvelope:
62
+ id: Sulid
63
+ "The ID of the post, AKA the sulid (signed-ulid)"
64
+
65
+ signer: PubKey
66
+ signature: Signature
67
+
68
+ raw_envelope: bytes = field(repr=False)
69
+ """
70
+ The canonical messagepack encoding of the envelope contents.
71
+
72
+ This makes it easier to verify the signature on an envelope.
73
+ (i.e.: we don't have to implement canonicalization in Python.)
74
+ """
75
+
76
+ envelope: Envelope
77
+
78
+ def assert_valid(self) -> None:
79
+ # We have everything we need to validate this, but no real need yet since the Rust implementation
80
+ # should be able to validate faster.
81
+ raise NotImplementedError()
82
+
83
+ @dataclass(slots=True, kw_only=True)
84
+ class Envelope:
85
+ """The envelope for a post describes its contents."""
86
+
87
+ tz_offset: int|None = None
88
+ """Offset in minutes from UTC. negative = westward"""
89
+
90
+ files: list[PostFile]
91
+
92
+ @staticmethod
93
+ def from_msgpack(msg: bytes) -> Envelope:
94
+ parts = msgpack.unpackb(msg)
95
+ return Envelope(
96
+ tz_offset = getattr(parts, "tz_offset", None),
97
+ files = [PostFile.from_mpack(f) for f in parts["files"]]
98
+ )
99
+
100
+ @dataclass(slots=True, kw_only=True)
101
+ class PostFile:
102
+ "file metadata included in an Envelope"
103
+ name: str
104
+ size: int
105
+ hash: Blake3Hash
106
+
107
+ @staticmethod
108
+ def from_mpack(parts: Any) -> PostFile: # noqa: ANN401 # type checked by PostFile()
109
+ return PostFile(
110
+ name = parts["name"],
111
+ size = parts["size"],
112
+ hash = Blake3Hash(parts["hash"])
113
+ )
114
+
115
+
116
+ @dataclass(slots=True, frozen=True)
117
+ class B58Bytes:
118
+ """Just includes bytes, but has a __repr__ to show them as base58"""
119
+
120
+ bytes: bytes
121
+
122
+ def to_base58(self) -> str:
123
+ return base58.b58encode(self.bytes).decode()
124
+
125
+ def __str__(self) -> str:
126
+ return self.to_base58()
127
+
128
+ def __repr__(self) -> str:
129
+ return str(self)
130
+
131
+ @dataclass(frozen=True, slots=True, repr=False)
132
+ class PubKey(B58Bytes):
133
+ """A 32-byte ed25519 public key."""
134
+ bytes: Annotated[bytes, num_bytes(32)]
135
+
136
+
137
+ @dataclass(frozen=True, slots=True, repr=False)
138
+ class Signature(B58Bytes):
139
+ """A 64-byte ed25519 signature"""
140
+ bytes: Annotated[bytes, num_bytes(64)]
141
+
142
+ def is_valid(self, _pubkey: PubKey, _message: bytes) -> bool:
143
+ raise NotImplementedError()
144
+
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class Sulid:
149
+ """
150
+ A signed-ULID.
151
+
152
+ See: https://github.com/ulid/spec
153
+ And: https://docs.rs/signed-ulid/latest/signed_ulid/
154
+ """
155
+ @staticmethod
156
+ def from_string(value: str) -> Sulid:
157
+ raise NotImplementedError()
158
+
159
+
160
+ bytes: Annotated[bytes, num_bytes(16)]
161
+
162
+ def __str__(self) -> str:
163
+ return _Crockford32.encode(self.bytes)
164
+
165
+ def __repr__(self) -> str:
166
+ return str(self)
167
+
168
+
169
+ def _timestamp_ms(self) -> int:
170
+ # First 48 bites (6 bytes) are the timestamp.
171
+ return int(self.bytes[:6].hex(), 16)
172
+
173
+ def timestamp(self) -> datetime:
174
+ """Get the timestamp portion of the Sulid"""
175
+
176
+ return datetime.fromtimestamp(self._timestamp_ms() / 1000, UTC)
177
+
178
+
179
+ class _Crockford32:
180
+ # Python includes b32hexencode/decode, but not using the Crockford encoding.
181
+ # I wish those libraries would just accept an alphabet. :(
182
+ # Update:
183
+ # Aah. Crockford isn't just a different alphabet. It's based on encoding *integers*, not streams of bytes.
184
+ # Or, alternatively, you can think of it as "encoding from the right".
185
+ # See: https://github.com/ulid/javascript/blob/11c2067821ee19e4dc787ca4e0125a025485edc6/source/crockford.ts#L10-L19
186
+ # 1 b32 char = 5 bits.
187
+ # 1 byte = 8 bits (of course)
188
+ # LCM = 40 bits, or 8 b32 chars, or 5 bytes.
189
+
190
+ # See: https://en.wikipedia.org/wiki/Base32#Crockford's_Base32
191
+ # We're only encoding the "strict" alphabet, not the forgiving one for ambiguous characters.
192
+ alphabet = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"
193
+ b32hex_alphabet = b"0123456789ABCDEFGHIJKLMNOPQRSTUV"
194
+
195
+ encode_map: ClassVar = dict(zip(b32hex_alphabet, alphabet))
196
+ # decode_map = dict(zip(alphabet, b32hex_alphabet))
197
+
198
+ @staticmethod
199
+ def encode(value: bytes) -> str:
200
+ # "Crockford encoding" (or, at least, ULID's use of it) relies on bytes being right-aligned.
201
+ trim_front = 0
202
+ if (rem := len(value) % 5) != 0:
203
+ # Pad the left up to a multiple of 5 bytes.
204
+ pad = 5 - rem
205
+ value = b"\x00" * pad + value
206
+
207
+ # We know these are never significant so we can trim them:
208
+ trim_front = floor(pad * 8 / 5)
209
+
210
+
211
+ encoded = base64.b32hexencode(value, padded=False)
212
+ encoded = bytes(
213
+ _Crockford32.encode_map[letter]
214
+ for letter in encoded
215
+ )
216
+ return encoded[trim_front:].decode('ascii')
217
+
218
+
219
+
220
+
221
+ @dataclass(slots=True, frozen=True)
222
+ class Blake3Hash:
223
+ bytes: Annotated[bytes, num_bytes(32)]
224
+
225
+ def __str__(self) -> str:
226
+ return self.bytes.hex()
227
+
228
+ def __repr__(self) -> str:
229
+ return str(self)
230
+
231
+
232
+