Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ dist/
venv/
.flatpak-builder/
build-dir/
# Left by test and lint runs
.ruff_cache/
.hypothesis/
.coverage
.coverage.*
htmlcov/
180 changes: 180 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ prune packaging
# ...except the dependency lock, which install.sh uses when installing from an sdist
include packaging/requirements.lock
include packaging/build-backend.lock
# The example program for the public API (rnfs.Node, rnfs.Client)
recursive-include examples *.py
143 changes: 121 additions & 22 deletions README.md

Large diffs are not rendered by default.

763 changes: 627 additions & 136 deletions design.md

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions examples/rnfs_share.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Share a file or folder from a program with rnfs's public API, through either class (provisional until 0.2).

python3 examples/rnfs_share.py --node ~/.rnfs-example PATH # a node inside this program, beside its Reticulum
python3 examples/rnfs_share.py --daemon PATH # the user's running daemon (rnfs daemon)

It adds PATH, lists it, reads 4 KiB from the middle of a file in it, publishes it under the name "example" and
watches that name, then gets it back through the name with a job showing progress. The line constructing
rnfs.Node or rnfs.Client is the only difference between the two.
"""
import argparse
import os
import sys
import tempfile
import threading

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # run from a source checkout

import rnfs # noqa: E402


def share(node: rnfs.Rnfs, path: str) -> str:
"""Add path through node, look at it, publish it and get it back; the path it was got back to."""
added = node.add(path)
print(f"added {added.cid} ({added.size} bytes)")
for warning in added.warnings:
print(f" warning: {warning}")
target = str(added.cid)
if os.path.isdir(path):
entries = node.ls(added.cid)
for entry in entries:
print(f" {entry.kind:10} {entry.size:>12} {entry.name}")
files = [entry for entry in entries if not entry.is_dir]
target = f"{added.cid}/{files[0].name}" if files else None
if target is not None:
with node.open(target) as f:
f.seek(f.size // 2) # only the chunks this read covers are fetched
data = f.read(4096)
print(f"read {len(data)} bytes from the middle of {target}")

mine = {name.alias: name for name in node.names.list()}
name = mine.get("example") or node.names.create("example")
heard = threading.Event()

def on_record(record: rnfs.Record):
"""Called on the node's event thread when a newer record of the name is heard (here: our publish)."""
print(f"heard rnfs:name/{record.name.hex()} -> {record.cid} (sequence {record.sequence})")
heard.set()

with node.names.watch(name, on_record):
record = name.publish(added.cid)
print(f"published rnfs:name/{name.hex} -> {record.cid}")
heard.wait(10)

output = os.path.join(tempfile.mkdtemp(), os.path.basename(os.path.normpath(path)) or "copy")

def on_progress(job: rnfs.Job):
"""Called on the event thread as the job goes on, and once when it ends."""
print(f" {job.state}: {job.done_bytes} of {job.total_bytes if job.total_bytes is not None else '?'} bytes")

job = node.jobs.get(f"rnfs:name/{name.hex}", output, on_progress=on_progress).wait()
if job.state != "done":
raise rnfs.RnfsError(f"the job {job.state}: {job.error}")
print(f"got it back as {output}")
return output


def main():
"""Parse the arguments, make the Node or the Client, and share."""
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
where = parser.add_mutually_exclusive_group(required=True)
where.add_argument("--node", metavar="DIR", help="run an rnfs node inside this program, in DIR")
where.add_argument("--daemon", action="store_true", help="use the running rnfs daemon")
parser.add_argument("path", help="a file or folder to share")
args = parser.parse_args()
try:
if args.node:
import RNS
RNS.Reticulum() # the program's own Reticulum, from RNS's default config directory
instance = rnfs.Node(args.node)
else:
instance = rnfs.Client()
with instance as node:
share(node, args.path)
except rnfs.RnfsError as e: # every rnfs error, from either class
sys.exit(f"rnfs_share: {e}")


if __name__ == "__main__":
main()
4 changes: 3 additions & 1 deletion packaging/pyinstaller/rnfs.spec
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ def required(package: str) -> str:
VERSION = rnfs_version()

hiddenimports = (
collect_submodules("rnfs") # rnfs.features loads feature modules by name with importlib
# rnfs.features loads feature modules by name with importlib, and rnfs.statedb its migration's importers
collect_submodules("rnfs")
+ ["sqlite3"] # the state database; imported inside a function, so named here (PyInstaller adds _sqlite3)
+ collect_submodules(required("RNS"))
+ collect_submodules(required("LXMF"))
+ collect_submodules("serial") # pyserial: Serial, KISS and RNode interfaces
Expand Down
36 changes: 34 additions & 2 deletions packaging/smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
(no shared instance, so a running rnsd isn't touched; one TCP server interface on 127.0.0.1 and nothing that
reaches the network), on free ports, polls `status` until the daemon answers, checks the TCP interface is
listening (which proves RNS found its dynamically loaded interface modules, the part a frozen build can
miss), stops the daemon the way Ctrl+C would and checks it exits. Exits non-zero on any failure.
Uses only the standard library, so any Python 3 can run it.
miss), adds a file, stops the daemon the way Ctrl+C would and checks it exits. The state database must then
hold the pin, and a JSON state file of rnfs 0.1.4 placed in the directory beforehand must have been imported
into it (which proves the build has sqlite3 and the modules the migration imports by name). Exits non-zero on
any failure. Uses only the standard library, so any Python 3 can run it.
"""
import json
import os
import signal
import socket
import sqlite3
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -80,6 +84,14 @@ def main():
interface_port = free_port()
f.write(RETICULUM_CONFIG.format(port=interface_port))
ports = ["--dir", rnfs_dir, "--api-port", str(free_port())]
# A state file as rnfs 0.1.4 wrote it, which the daemon imports into its state database at startup
state_dir = os.path.join(rnfs_dir, "state")
os.makedirs(state_dir, mode=0o700)
with open(os.path.join(state_dir, "pin_names.json"), "w", encoding="utf-8") as f:
json.dump({}, f)
added = os.path.join(tmp, "added.txt")
with open(added, "w", encoding="utf-8") as f:
f.write("smoke test")
log_path = os.path.join(tmp, "daemon.log")
with open(log_path, "w", encoding="utf-8") as log:
daemon = subprocess.Popen(
Expand All @@ -104,6 +116,7 @@ def main():
sys.exit(f"smoke test: RNS's TCP interface isn't listening on {interface_port} ({e}): "
"were RNS's interface modules bundled?")
print(f"RNS TCP interface listening on 127.0.0.1:{interface_port}")
run(command, "add", *ports, added)
finally:
if daemon.poll() is None:
daemon.send_signal(signal.CTRL_BREAK_EVENT if WINDOWS else signal.SIGINT)
Expand All @@ -122,8 +135,27 @@ def main():
print("--- daemon log ---\n" + log.read().rstrip() + "\n------------------")
if not stopped_cleanly:
sys.exit("smoke test: daemon didn't stop cleanly")
check_state(state_dir)
print("smoke test passed")


def check_state(state_dir: str):
"""Exit unless the state database holds a pin and records pin_names.json as imported."""
path = os.path.join(state_dir, "rnfs.db")
if not os.path.isfile(path):
sys.exit(f"smoke test: no state database at {path}")
conn = sqlite3.connect(path)
try:
pins = conn.execute("SELECT count(*) FROM pins").fetchone()[0]
migrated = [row[0] for row in conn.execute("SELECT file FROM migrated")]
finally:
conn.close()
print(f"state database: {pins} pin(s), imported {migrated}")
if pins < 1:
sys.exit("smoke test: the file added isn't pinned in the state database")
if migrated != ["pin_names.json"] or not os.path.exists(os.path.join(state_dir, "pin_names.json.migrated")):
sys.exit("smoke test: pin_names.json wasn't imported into the state database")


if __name__ == "__main__":
main()
42 changes: 41 additions & 1 deletion rnfs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
"""rnfs: content-addressed file sharing over Reticulum (RNS).

For programs (design.md, "Public API"). PROVISIONAL until rnfs 0.2 (API_STATUS): names, signatures and objects
may still change. Two classes with the same methods, objects and exceptions, so a program switches by changing
the line that constructs one:
- rnfs.Node(rnfs_dir, reticulum=..., settings=...) runs a node inside the program, beside its own Reticulum;
- rnfs.Client(rnfs_dir=None) uses the user's running daemon through its local API (it never loads RNS).
Every public name is loaded when first used, so `import rnfs` loads nothing more, and RNS only with rnfs.Node.

content/ CIDs, DAGs, storage, pins, archives, shared folder (no RNS)
dht/ Kademlia routing table, provider records, iterative lookups (no RNS)
network/ everything that talks to RNS: identity, links, request client and server, announces
Expand All @@ -8,7 +15,40 @@
publisher.py provide strategy, publishing and withdrawal in the DHT
node.py RnfsNode, which ties the above together into a running node and offers the facade
api.py local HTTP API; gateway.py the HTTP gateway
debuglog.py the debug log file (debug_log)
cli.py command line: run a daemon, or talk to one through its API
interface.py, objects.py, embedded.py, remote.py: the public API (rnfs.Node, rnfs.Client)
"""

__version__ = "0.1.4"
__version__ = "0.1.5"
API_STATUS = "provisional" # the public API (rnfs.Node, rnfs.Client) may change until rnfs 0.2

# Public name -> the module defining it, imported on first use (PEP 562)
_EXPORTS = {
"Node": "rnfs.embedded",
"Client": "rnfs.remote",
**{name: "rnfs.objects" for name in ("Cid", "Added", "Written", "Entry", "Info", "Stat", "Pin", "Record",
"Status", "Collected", "Verified", "Exported", "Imported", "DenyState")},
**{name: "rnfs.interface" for name in ("Rnfs", "Name", "Names", "Job", "Jobs", "Deny", "Watch", "File")},
**{name: "rnfs.errors" for name in ("RnfsError", "NotFound", "TooLarge", "InvalidDag", "StorageFull", "Busy",
"Denied", "Cancelled", "Closed")},
**{name: "rnfs.apiclient" for name in ("ApiError", "DaemonNotRunning", "ConnectionFailed", "TokenError",
"AuthFailed", "SettingsError", "BadRequest", "ServerError")},
}
__all__ = ["API_STATUS", "__version__", *_EXPORTS]


def __getattr__(name: str):
"""A public name, its module imported now."""
module = _EXPORTS.get(name)
if module is None:
raise AttributeError(f"module 'rnfs' has no attribute {name!r}")
import importlib
value = getattr(importlib.import_module(module), name)
globals()[name] = value
return value


def __dir__() -> list[str]:
"""The module's names, the public ones included."""
return sorted({*globals(), *_EXPORTS})
Loading
Loading