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 +26 -0
- nytt-0.1.0/README.md +13 -0
- nytt-0.1.0/pyproject.toml +37 -0
- nytt-0.1.0/pyproject.toml.orig +40 -0
- nytt-0.1.0/src/nytt/__init__.py +14 -0
- nytt-0.1.0/src/nytt/_validators.py +14 -0
- nytt-0.1.0/src/nytt/cli.py +124 -0
- nytt-0.1.0/src/nytt/helpers.py +40 -0
- nytt-0.1.0/src/nytt/types.py +232 -0
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
|
+
|