diff --git a/backup/README.md b/backup/README.md index cb90a7c51..a713fd312 100644 --- a/backup/README.md +++ b/backup/README.md @@ -11,7 +11,8 @@ Related info about backup solutions: https://github.com/ElementsProject/lightnin ## Installation You need [uv](https://docs.astral.sh/uv/getting-started/installation/) to run this -plugin like a binary. After `uv` is installed you can simply run +plugin and `backup-cli` like a binary. After `uv` is installed and you followed the +[Setup](#setup) step you can simply run ``` lightning-cli plugin start /path/to/backup.py @@ -30,7 +31,7 @@ which makes sure no two instances are using the same backup. (Make sure to stop your Lightning node before running this command) ```bash -uv run ./backup-cli init --lightning-dir ~/.lightning/bitcoin file:///mnt/external/location/file.bkp +./backup-cli init --lightning-dir ~/.lightning/bitcoin file:///mnt/external/location/file.bkp ``` Notes: @@ -40,7 +41,8 @@ Notes: - You should use some non-local SSH or NFS mount as destination, otherwise any failure of the disk may result in both the original as well as the backup being corrupted. - - Currently only the `file:///` URL scheme is supported. + - There is support for local filesystems with the `file:///` URL scheme and + remote support with the `socket:` URL scheme (see [remote](remote.md)). ## IMPORTANT note about hsm_secret diff --git a/backup/backends.py b/backup/backends.py index 668807233..52eba677d 100644 --- a/backup/backends.py +++ b/backup/backends.py @@ -1,6 +1,6 @@ """Create a backend instance based on URI scheme dispatch.""" -from typing import Type +from typing import Mapping, Type from urllib.parse import urlparse from backend import Backend diff --git a/backup/backup-cli b/backup/backup-cli index 9290fa596..c5de14762 100755 --- a/backup/backup-cli +++ b/backup/backup-cli @@ -1,4 +1,13 @@ -#!/usr/bin/env python3 +#!/usr/bin/env -S uv run --script + +# /// script +# requires-python = ">=3.9.2" +# dependencies = [ +# "click>=8.0.4", +# "requests[socks]>=2.32.4", +# ] +# /// + from backends import get_backend from backend import Change from server import SocketServer, setup_server_logging @@ -15,13 +24,19 @@ root.setLevel(logging.INFO) handler = logging.StreamHandler(sys.stdout) handler.setLevel(logging.DEBUG) -formatter = logging.Formatter('%(message)s') +formatter = logging.Formatter("%(message)s") handler.setFormatter(formatter) root.addHandler(handler) + @click.command() @click.argument("backend-url") -@click.option('--lightning-dir', type=click.Path(exists=True), default=None, help='Use an existing lightning directory (default: initialize an empty backup).') +@click.option( + "--lightning-dir", + type=click.Path(exists=True), + default=None, + help="Use an existing lightning directory (default: initialize an empty backup).", +) def init(lightning_dir, backend_url): destination = backend_url backend = get_backend(destination, create=True) @@ -31,13 +46,21 @@ def init(lightning_dir, backend_url): db_file = os.path.join(lightning_dir, "lightningd.sqlite3") with open(lock_file, "w") as f: - f.write(json.dumps({ - 'backend_url': destination, - })) + f.write( + json.dumps( + { + "backend_url": destination, + } + ) + ) data_version = 0 if os.path.exists(db_file): - print("Found an existing database at {db_file}, initializing the backup with a snapshot".format(db_file=db_file)) + print( + "Found an existing database at {db_file}, initializing the backup with a snapshot".format( + db_file=db_file + ) + ) # Peek into the DB to see if we have db = sqlite3.connect(db_file) cur = db.cursor() @@ -46,20 +69,26 @@ def init(lightning_dir, backend_url): snapshot = Change( version=data_version, - snapshot=open(db_file, 'rb').read(), - transaction=None + snapshot=open(db_file, "rb").read(), + transaction=None, ) if not backend.add_change(snapshot): print("Could not write snapshot to backend") sys.exit(1) else: - print("Successfully written initial snapshot to {destination}".format(destination=destination)) + print( + "Successfully written initial snapshot to {destination}".format( + destination=destination + ) + ) else: print("Database does not exist yet, created an empty backup file") - print("Initialized backup backend {destination}, you can now start Core-Lightning".format( - destination=destination, - )) + print( + "Initialized backup backend {destination}, you can now start Core-Lightning".format( + destination=destination, + ) + ) @click.command() @@ -74,11 +103,24 @@ def restore(backend_url, restore_destination): @click.command() @click.argument("backend-url") @click.argument("addr") -@click.option('--log-mode', type=click.Choice(['plain', 'systemd'], case_sensitive=False), default='plain', help='Debug log mode, defaults to plain') -@click.option('--log-level', type=click.Choice(['debug', 'info', 'notice', 'warning', 'error', 'critical'], case_sensitive=False), default='info', help='Debug log level, defaults to info') +@click.option( + "--log-mode", + type=click.Choice(["plain", "systemd"], case_sensitive=False), + default="plain", + help="Debug log mode, defaults to plain", +) +@click.option( + "--log-level", + type=click.Choice( + ["debug", "info", "notice", "warning", "error", "critical"], + case_sensitive=False, + ), + default="info", + help="Debug log level, defaults to info", +) def server(backend_url, addr, log_mode, log_level): backend = get_backend(backend_url) - addr, port = addr.split(':') + addr, port = addr.split(":") port = int(port) setup_server_logging(log_mode, log_level) diff --git a/backup/backup.py b/backup/backup.py index c69eb3897..ac69eb419 100755 --- a/backup/backup.py +++ b/backup/backup.py @@ -4,10 +4,7 @@ # requires-python = ">=3.9.2" # dependencies = [ # "pyln-client>=25.2.2", -# "click>=8.0.4", # "psutil>=5.9.4", -# "flask>=2.2", -# "werkzeug<4", # ] # /// @@ -24,14 +21,7 @@ plugin = Plugin() -root = logging.getLogger() -root.setLevel(logging.INFO) - -handler = logging.StreamHandler(sys.stdout) -handler.setLevel(logging.DEBUG) -formatter = logging.Formatter("%(message)s") -handler.setFormatter(formatter) -root.addHandler(handler) +logging.getLogger().setLevel(logging.INFO) def check_first_write(plugin, data_version): @@ -49,18 +39,18 @@ def check_first_write(plugin, data_version): """ backend = plugin.backend - logging.info( + plugin.log( "Comparing backup version {} versus first write version {}".format( backend.version, data_version ) ) if backend.version == data_version - 1: - logging.info("Versions match up") + plugin.log("Versions match up") return True elif backend.prev_version == data_version - 1 and plugin.backend.rewind(): - logging.info("Last changes not applied, rewinding non-committed transaction") + plugin.log("Last changes not applied, rewinding non-committed transaction") return True elif backend.prev_version > data_version - 1: @@ -154,5 +144,5 @@ def kill(message: str): plugin.backend = get_backend(destination, require_init=True) plugin.run() except Exception: - logging.exception("Exception while initializing backup plugin") + plugin.log("Exception while initializing backup plugin", level="error") kill("Exception while initializing plugin, terminating lightningd") diff --git a/backup/conftest.py b/backup/conftest.py new file mode 100644 index 000000000..d948d43ad --- /dev/null +++ b/backup/conftest.py @@ -0,0 +1,86 @@ +import pytest +import os +import subprocess +import time +import socket +import asyncio +import threading + +from pyln.testing.fixtures import * # noqa: F403 +from asysocks.server import SOCKSServer + +cli_path = os.path.join(os.path.dirname(__file__), "backup-cli") + + +def wait_for_port(host, port, timeout=5.0): + """Poll until a TCP port starts accepting connections.""" + start = time.time() + while time.time() - start < timeout: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.2) + try: + s.connect((host, port)) + return + except (ConnectionRefusedError, OSError): + time.sleep(0.05) + raise RuntimeError(f"Backup server did not start listening on {host}:{port}") + + +@pytest.fixture +def running_backup_server(node_factory, directory): + """ + Spins up a local backup server on a random port for testing. + Yields (host, port). + """ + + file_url = "file://" + os.path.join(directory, "backup.dbak") + + subprocess.check_call([cli_path, "init", file_url]) + + host = "127.0.0.1" + port = node_factory.get_unused_port() + + # Start server + server_proc = subprocess.Popen( + [cli_path, "server", file_url, f"{host}:{port}"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + try: + # Wait until server is actually listening + wait_for_port(host, port, timeout=5) + yield host, port + finally: + server_proc.terminate() + try: + server_proc.wait(timeout=2) + except subprocess.TimeoutExpired: + server_proc.kill() + + +@pytest.fixture +def socks5_proxy(node_factory): + """ + Spins up a local SOCKS5 proxy server on a random port for testing. + Yields (host, port) as a plain tuple. + """ + host = "127.0.0.1" + port = node_factory.get_unused_port() + + server = SOCKSServer(host, port) + + def run_server(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(server.run()) + finally: + loop.close() + + thread = threading.Thread(target=run_server, daemon=True) + thread.start() + + wait_for_port(host, port) + + yield host, port diff --git a/backup/filebackend.py b/backup/filebackend.py index 2195e3189..48ae99d23 100644 --- a/backup/filebackend.py +++ b/backup/filebackend.py @@ -167,7 +167,7 @@ def compact(self): }, } - print("Starting compaction: stats={}".format(stats)) + logging.info("Starting compaction: stats={}".format(stats)) self.db = self._db_open(snapshotpath) for change in self.stream_changes(): @@ -205,7 +205,7 @@ def compact(self): snapshot=open(snapshotpath, "rb").read(), transaction=None, ) - print( + logging.info( "Adding intial snapshot with {} bytes for version {}".format( len(snapshot.snapshot), snapshot.version ) @@ -224,7 +224,7 @@ def compact(self): "backupsize": os.stat(clonepath).st_size, } - print( + logging.info( "Compacted {} changes, saving {} bytes, swapping backups".format( stats["before"]["version_count"] - stats["after"]["version_count"], stats["before"]["backupsize"] - stats["after"]["backupsize"], diff --git a/backup/pyproject.toml b/backup/pyproject.toml index c9ba97364..529fe26c6 100644 --- a/backup/pyproject.toml +++ b/backup/pyproject.toml @@ -9,8 +9,7 @@ dependencies = [ "pyln-client>=25.2.2", "click>=8.0.4", "psutil>=5.9.4", - "flask>=2.2", - "werkzeug<4", + "requests[socks]>=2.32.4", ] [dependency-groups] @@ -18,5 +17,7 @@ dev = [ "pyln-testing>=25.2.2", "pytest-timeout>=2.2.0", "pytest-xdist>=3.1.0", + "pytest-asyncio>=0.23.8,<2", "flaky>=3.7.0", + "asysocks<0.3", ] diff --git a/backup/remote.md b/backup/remote.md index c76618903..45e17673d 100644 --- a/backup/remote.md +++ b/backup/remote.md @@ -28,8 +28,8 @@ Usage First initialize an empty file backend on the server side, then start the server: ```bash -backup-cli init file:///path/to/backup -backup-cli server file:///path/to/backup 127.0.0.1:8700 +./backup-cli init file:///path/to/backup +./backup-cli server file:///path/to/backup 127.0.0.1:8700 ``` On the client side: @@ -38,7 +38,7 @@ On the client side: # Make sure Core-Lightning is not running lightning-cli stop # Initialize the socket backend (this makes an initial snapshot, and creates a configuration file for the plugin) -backup-cli init socket:127.0.0.1:8700 --lightning-dir "$HOME/.lightning/bitcoin" +./backup-cli init socket:127.0.0.1:8700 --lightning-dir "$HOME/.lightning/bitcoin" # Start c-lighting, with the backup plugin as important plugin so that any issue with it stops the daemon lightningd ... \ --important-plugin /path/to/plugins/backup/backup.py diff --git a/backup/socketbackend.py b/backup/socketbackend.py index 30d84f058..67ee96eb7 100644 --- a/backup/socketbackend.py +++ b/backup/socketbackend.py @@ -3,6 +3,7 @@ import logging import socket import re +import socks import struct import time from typing import Tuple, Iterator @@ -124,7 +125,6 @@ def connect(self): self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) else: assert self.url.proxytype == ProxyType.SOCKS5 - import socks self.sock = socks.socksocket() self.sock.set_proxy( diff --git a/backup/test_backup.py b/backup/test_backup.py index fb75d1e4c..bef5a6894 100644 --- a/backup/test_backup.py +++ b/backup/test_backup.py @@ -13,8 +13,7 @@ plugin_path = os.path.join(plugin_dir, "backup.py") cli_path = os.path.join(os.path.dirname(__file__), "backup-cli") -# For the transition period we require deprecated_apis to be true -deprecated_apis = True +deprecated_apis = False def test_start(node_factory, directory): @@ -39,6 +38,29 @@ def test_start(node_factory, directory): assert "backup.py" in plugins +def test_start_socket(node_factory, directory, running_backup_server): + host, port = running_backup_server + bpath = os.path.join(directory, "lightning-1", "regtest") + bdest = f"socket:{host}:{port}" + os.makedirs(bpath) + subprocess.check_call([cli_path, "init", "--lightning-dir", bpath, bdest]) + opts = { + "plugin": plugin_path, + "allow-deprecated-apis": deprecated_apis, + } + l1 = node_factory.get_node(options=opts, cleandir=False) + plugins = [os.path.basename(p["name"]) for p in l1.rpc.plugin("list")["plugins"]] + assert "backup.py" in plugins + + # Restart the node a couple of times, to check that we can resume normally + for i in range(5): + l1.restart() + plugins = [ + os.path.basename(p["name"]) for p in l1.rpc.plugin("list")["plugins"] + ] + assert "backup.py" in plugins + + def test_start_no_init(node_factory, directory): """The plugin should refuse to start if we haven't initialized the backup""" bpath = os.path.join(directory, "lightning-1", "regtest") @@ -76,6 +98,29 @@ def test_init_not_empty(node_factory, directory): assert l1.daemon.is_in_log(r"plugin-backup.py: Versions match up") +def test_init_not_empty_socket(node_factory, directory, running_backup_server): + """We want to add backups to an existing lightning node. + + backup-cli init should start the backup with an initial snapshot. + """ + host, port = running_backup_server + bpath = os.path.join(directory, "lightning-1", "regtest") + bdest = f"socket:{host}:{port}" + os.makedirs(bpath) + l1 = node_factory.get_node() + l1.stop() + + out = subprocess.check_output([cli_path, "init", "--lightning-dir", bpath, bdest]) + assert b"Found an existing database" in out + assert b"Successfully written initial snapshot" in out + + # Now restart and add the plugin + l1.daemon.opts["plugin"] = plugin_path + l1.daemon.opts["allow-deprecated-apis"] = deprecated_apis + l1.start() + assert l1.daemon.is_in_log(r"plugin-backup.py: Versions match up") + + @flaky def test_tx_abort(node_factory, directory): """Simulate a crash between hook call and DB commit. @@ -109,6 +154,40 @@ def test_tx_abort(node_factory, directory): assert l1.daemon.is_in_log(r"Last changes not applied") +@flaky +def test_tx_abort_socket(node_factory, directory, running_backup_server): + """Simulate a crash between hook call and DB commit. + + We simulate this by updating the data_version var in the database before + restarting the node. This desyncs the node from the backup, and restoring + may not work (depending on which transaction was pretend-rolled-back), but + continuing should work fine, since it can happen that we crash just + inbetween the hook call and the DB transaction. + + """ + host, port = running_backup_server + bpath = os.path.join(directory, "lightning-1", "regtest") + bdest = f"socket:{host}:{port}" + os.makedirs(bpath) + subprocess.check_call([cli_path, "init", "--lightning-dir", bpath, bdest]) + opts = { + "plugin": plugin_path, + "allow-deprecated-apis": deprecated_apis, + } + l1 = node_factory.get_node(options=opts, cleandir=False) + l1.stop() + + print(l1.db.query("SELECT * FROM vars;")) + + # Now fudge the data_version: + l1.db.execute("UPDATE vars SET intval = intval - 1 WHERE name = 'data_version'") + + print(l1.db.query("SELECT * FROM vars;")) + + l1.restart() + assert l1.daemon.is_in_log(r"Last changes not applied") + + @flaky def test_failing_restore(node_factory, directory): """The node database is having memory loss, make sure we abort. @@ -146,6 +225,44 @@ def section(comment): assert l1.daemon.is_in_log(r"lost some state") is not None +@flaky +def test_failing_restore_socket(node_factory, directory, running_backup_server): + """The node database is having memory loss, make sure we abort. + + We simulate a loss of transactions by manually resetting the data_version + in the database back to n-2, which is non-recoverable. + + """ + host, port = running_backup_server + bpath = os.path.join(directory, "lightning-1", "regtest") + bdest = f"socket:{host}:{port}" + os.makedirs(bpath) + subprocess.check_call([cli_path, "init", "--lightning-dir", bpath, bdest]) + opts = { + "plugin": plugin_path, + "allow-deprecated-apis": deprecated_apis, + } + + def section(comment): + print("=" * 25, comment, "=" * 25) + + section("Starting node for the first time") + l1 = node_factory.get_node(options=opts, cleandir=False, may_fail=True) + l1.stop() + + # Now fudge the data_version: + section("Simulating a restore of an old version") + l1.db.execute("UPDATE vars SET intval = intval - 2 WHERE name = 'data_version'") + + section("Restarting node, should fail") + with pytest.raises(Exception): + l1.start() + + l1.daemon.proc.wait() + section("Verifying the node died with an error") + assert l1.daemon.is_in_log(r"lost some state") is not None + + def test_intermittent_backup(node_factory, directory): """Simulate intermittent use of the backup, or an old file backup.""" bpath = os.path.join(directory, "lightning-1", "regtest") @@ -173,6 +290,34 @@ def test_intermittent_backup(node_factory, directory): assert l1.daemon.is_in_log(r"Backup is out of date") is not None +def test_intermittent_backup_socket(node_factory, directory, running_backup_server): + """Simulate intermittent use of the backup, or an old file backup.""" + host, port = running_backup_server + bpath = os.path.join(directory, "lightning-1", "regtest") + bdest = f"socket:{host}:{port}" + os.makedirs(bpath) + subprocess.check_call([cli_path, "init", "--lightning-dir", bpath, bdest]) + opts = { + "plugin": plugin_path, + "allow-deprecated-apis": deprecated_apis, + } + l1 = node_factory.get_node(options=opts, cleandir=False, may_fail=True) + + # Now start without the plugin. This should work fine. + del l1.daemon.opts["plugin"] + l1.restart() + + # Now restart adding the plugin again, and it should fail due to gaps in + # the backup. + l1.stop() + with pytest.raises(Exception): + l1.daemon.opts.update(opts) + l1.start() + + l1.daemon.proc.wait() + assert l1.daemon.is_in_log(r"Backup is out of date") is not None + + def test_restore(node_factory, directory): bpath = os.path.join(directory, "lightning-1", "regtest") bdest = "file://" + os.path.join(bpath, "backup.dbak") @@ -189,6 +334,23 @@ def test_restore(node_factory, directory): subprocess.check_call([cli_path, "restore", bdest, rdest]) +def test_restore_socket(node_factory, directory, running_backup_server): + host, port = running_backup_server + bpath = os.path.join(directory, "lightning-1", "regtest") + bdest = f"socket:{host}:{port}" + os.makedirs(bpath) + subprocess.check_call([cli_path, "init", "--lightning-dir", bpath, bdest]) + opts = { + "plugin": plugin_path, + "allow-deprecated-apis": deprecated_apis, + } + l1 = node_factory.get_node(options=opts, cleandir=False) + l1.stop() + + rdest = os.path.join(bpath, "lightningd.sqlite.restore") + subprocess.check_call([cli_path, "restore", bdest, rdest]) + + def test_restore_dir(node_factory, directory): bpath = os.path.join(directory, "lightning-1", "regtest") bdest = "file://" + os.path.join(bpath, "backup.dbak") @@ -210,6 +372,28 @@ def test_restore_dir(node_factory, directory): subprocess.check_call([cli_path, "restore", bdest, bpath]) +def test_restore_dir_socket(node_factory, directory, running_backup_server): + host, port = running_backup_server + bpath = os.path.join(directory, "lightning-1", "regtest") + bdest = f"socket:{host}:{port}" + os.makedirs(bpath) + subprocess.check_call([cli_path, "init", "--lightning-dir", bpath, bdest]) + opts = { + "plugin": plugin_path, + "allow-deprecated-apis": deprecated_apis, + } + l1 = node_factory.get_node(options=opts, cleandir=False) + l1.stop() + + # should raise error without remove_existing + with pytest.raises(Exception): + subprocess.check_call([cli_path, "restore", bdest, bpath]) + + # but succeed when we remove the sqlite3 dbfile before + os.remove(os.path.join(bpath, "lightningd.sqlite3")) + subprocess.check_call([cli_path, "restore", bdest, bpath]) + + def test_warning(directory, node_factory): bpath = os.path.join(directory, "lightning-1", "regtest") bdest = "file://" + os.path.join(bpath, "backup.dbak") @@ -268,6 +452,8 @@ def test_compact(bitcoind, directory, node_factory): l1 = node_factory.get_node(options=opts, cleandir=False) l1.rpc.backup_compact() + l1.daemon.wait_for_log("Starting compaction") + tmp = tempfile.TemporaryDirectory() subprocess.check_call([cli_path, "restore", bdest, tmp.name]) @@ -280,6 +466,62 @@ def test_compact(bitcoind, directory, node_factory): subprocess.check_call([cli_path, "restore", bdest, tmp.name]) +def test_compact_socket(bitcoind, directory, node_factory, running_backup_server): + host, port = running_backup_server + bpath = os.path.join(directory, "lightning-1", "regtest") + bdest = f"socket:{host}:{port}" + os.makedirs(bpath) + subprocess.check_call([cli_path, "init", "--lightning-dir", bpath, bdest]) + opts = { + "plugin": plugin_path, + "allow-deprecated-apis": deprecated_apis, + } + l1 = node_factory.get_node(options=opts, cleandir=False) + l1.rpc.backup_compact() + + l1.stop() + + tmp = tempfile.TemporaryDirectory() + subprocess.check_call([cli_path, "restore", bdest, tmp.name]) + + l1.start() + + # Trigger a couple more changes and the compact again. + bitcoind.generate_block(100) + sync_blockheight(bitcoind, [l1]) + + l1.rpc.backup_compact() + + l1.stop() + + tmp = tempfile.TemporaryDirectory() + subprocess.check_call([cli_path, "restore", bdest, tmp.name]) + + +def test_socket_proxy(node_factory, directory, running_backup_server, socks5_proxy): + host, port = running_backup_server + proxy_host, proxy_port = socks5_proxy + bpath = os.path.join(directory, "lightning-1", "regtest") + bdest = f"socket:{host}:{port}?proxy=socks5:{proxy_host}:{proxy_port}" + os.makedirs(bpath) + subprocess.check_call([cli_path, "init", "--lightning-dir", bpath, bdest]) + opts = { + "plugin": plugin_path, + "allow-deprecated-apis": deprecated_apis, + } + l1 = node_factory.get_node(options=opts, cleandir=False) + plugins = [os.path.basename(p["name"]) for p in l1.rpc.plugin("list")["plugins"]] + assert "backup.py" in plugins + + # Restart the node a couple of times, to check that we can resume normally + for i in range(5): + l1.restart() + plugins = [ + os.path.basename(p["name"]) for p in l1.rpc.plugin("list")["plugins"] + ] + assert "backup.py" in plugins + + def test_parse_socket_url(): with pytest.raises(ValueError): # fail: invalid url scheme diff --git a/backup/uv.lock b/backup/uv.lock index 8901ba7db..fbb8890a3 100644 --- a/backup/uv.lock +++ b/backup/uv.lock @@ -15,6 +15,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, ] +[[package]] +name = "asysocks" +version = "0.2.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, + { name = "cryptography" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/67/62b03894335465f89bea910e33d15878b3c997bbaafaecf630bcdf8e89de/asysocks-0.2.18.tar.gz", hash = "sha256:cc6196e82c8adc8b3ce2321dddf635500c760316da4b7cca321b532cbb3dfdf5", size = 117822, upload-time = "2025-10-29T08:00:23.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b5/abf85f5444b264bf9f943eb1f6f577ebf3af34c8c4f66c1629d1f063eaa0/asysocks-0.2.18-py3-none-any.whl", hash = "sha256:a881536456d922a9fde44031aba262bc34ee39a9d14749ded1e8a4820602a37d", size = 149610, upload-time = "2025-10-29T08:00:21.76Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -24,6 +38,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "base58" version = "2.1.1" @@ -408,21 +431,39 @@ wheels = [ name = "cln-backup" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "psutil" }, + { name = "pyln-client" }, + { name = "requests", extra = ["socks"] }, +] [package.dev-dependencies] dev = [ + { name = "asysocks" }, { name = "flaky" }, { name = "pyln-testing" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, ] [package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0.4" }, + { name = "psutil", specifier = ">=5.9.4" }, + { name = "pyln-client", specifier = ">=25.2.2" }, + { name = "requests", extras = ["socks"], specifier = ">=2.32.4" }, +] [package.metadata.requires-dev] dev = [ + { name = "asysocks", specifier = "<0.3" }, { name = "flaky", specifier = ">=3.7.0" }, { name = "pyln-testing", specifier = ">=25.2.2" }, + { name = "pytest-asyncio", specifier = ">=0.23.8,<2" }, { name = "pytest-timeout", specifier = ">=2.2.0" }, { name = "pytest-xdist", specifier = ">=3.1.0" }, ] @@ -611,6 +652,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1071,6 +1121,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, + { name = "pytest", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-timeout" version = "2.4.0" @@ -1156,6 +1240,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + [[package]] name = "rpds-py" version = "0.27.1"