From e124787359c03f81ea23abe51a31389772c20704 Mon Sep 17 00:00:00 2001 From: antonio Date: Wed, 13 Nov 2024 17:06:19 -0500 Subject: [PATCH 01/16] ips: splitip better handling of ip addresses --- src/luxos/ips.py | 26 +++++++++++++++++++++----- tests/test_ips.py | 20 +++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/luxos/ips.py b/src/luxos/ips.py index 87dc240..d61937e 100644 --- a/src/luxos/ips.py +++ b/src/luxos/ips.py @@ -14,11 +14,27 @@ class DataParsingError(LuxosBaseException): pass -def splitip(txt: str) -> tuple[str, int | None]: - expr = re.compile(r"(?P\d{1,3}([.]\d{1,3}){3})(:(?P\d+))?") - if not (match := expr.search(txt)): - raise RuntimeError(f"invalid ip:port address {txt}") - return match["ip"], int(match["port"]) if match["port"] is not None else None +def splitip(txt: str, strict=True) -> tuple[str, int | None]: + if txt.count(":") not in {0, 1}: + raise ValueError("too many ':' in value") + + host, _, port_txt = txt.partition(":") + host = host.strip() + try: + port = int(port_txt) if port_txt else None + except ValueError: + raise ValueError(f"cannot convert '{port}' to an integer") + if not host.strip(): + raise ValueError(f"cannot find host part in '{txt}'") + + if not strict: + return host, port + + if re.search(r"(?P\d{1,3}([.]\d{1,3}){3})", host) and all( + int(c) < 256 for c in host.split(".") + ): + return host, port + raise ValueError(f"cannot convert '{host}' into an ipv4 address N.N.N.N") def parse_expr(txt: str) -> None | tuple[str, str | None, int | None]: diff --git a/tests/test_ips.py b/tests/test_ips.py index 4dbec53..d844aa7 100644 --- a/tests/test_ips.py +++ b/tests/test_ips.py @@ -49,9 +49,27 @@ def test_parse_expr(txt, expected): def test_splitip(): + with pytest.raises(ValueError) as e: + ips.splitip("") + assert e.value.args[-1] == "cannot find host part in ''" + + with pytest.raises(ValueError) as e: + ips.splitip("1:2:3") + assert e.value.args[-1] == "too many ':' in value" + + with pytest.raises(ValueError) as e: + ips.splitip("1:hello") + assert e.value.args[-1] == "cannot convert 'hello' to an integer" + assert ips.splitip("123.1.2.3") == ("123.1.2.3", None) assert ips.splitip("123.1.2.3:123") == ("123.1.2.3", 123) - pytest.raises(RuntimeError, ips.splitip, "123.1.2222.3:123") + + with pytest.raises(ValueError) as e: + ips.splitip("123.1.2222.3:123") + assert ( + e.value.args[-1] == "cannot convert '123.1.2222.3' into an ipv4 address N.N.N.N" + ) + assert ips.splitip("123.1.2222.3:123", strict=False) == ("123.1.2222.3", 123) def test_iter_ip_ranges(): From 655633dcd43e3d875013abbe237d58d718572ace Mon Sep 17 00:00:00 2001 From: antonio Date: Wed, 13 Nov 2024 17:12:07 -0500 Subject: [PATCH 02/16] syncops: handles 0x00 terminated packets --- src/luxos/syncops.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/luxos/syncops.py b/src/luxos/syncops.py index 4b76cad..59924fb 100644 --- a/src/luxos/syncops.py +++ b/src/luxos/syncops.py @@ -113,7 +113,10 @@ def _roundtrip( # the response will be and socket.recv() will block until reading # the specified number of bytes. while data := sock.recv(2**3): - response.append(data) + if b"\x00" in data: + response.append(data[: data.find(b"\x00")]) + else: + response.append(data) result = "".join(block.decode() for block in response) log.debug("received: %s", result) From 20513ab60ed30d667e9f94b3a510750150f7320a Mon Sep 17 00:00:00 2001 From: antonio Date: Wed, 13 Nov 2024 17:12:26 -0500 Subject: [PATCH 03/16] fix error return messages --- tests/test_asyncops.py | 2 +- tests/test_syncops.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_asyncops.py b/tests/test_asyncops.py index 305e23b..cd58244 100644 --- a/tests/test_asyncops.py +++ b/tests/test_asyncops.py @@ -280,7 +280,7 @@ async def test_roundtrip_timeout(miner_host_port): assert isinstance(exception, asyncio.TimeoutError) assert isinstance(exception, aapi.exceptions.MinerConnectionError) assert isinstance(exception, aapi.exceptions.LuxosBaseException) - text = f"<{host}:{port}>: MinerCommandTimeoutError, TimeoutError()" + text = f"<{host}:{port}>: MinerCommandTimeoutError, ConnectionRefu" assert str(exception)[: len(text)] == text # not miner on a wrong port diff --git a/tests/test_syncops.py b/tests/test_syncops.py index 275420f..9437449 100644 --- a/tests/test_syncops.py +++ b/tests/test_syncops.py @@ -111,14 +111,14 @@ def test_roundtrip_timeout(miner_host_port): # miner doesn't exist host, port = "127.0.0.99", 12345 try: - syncops.rexec(host, port, "hello", timeout=0.5) + syncops.rexec(host, port, "hello", timeout=0.5, retry=3) assert False, "didn't raise" except exceptions.MinerCommandTimeoutError as exc: exception = exc assert isinstance(exception, TimeoutError) assert isinstance(exception, exceptions.MinerConnectionError) assert isinstance(exception, exceptions.LuxosBaseException) - text = f"<{host}:{port}>: MinerCommandTimeoutError, TimeoutError(" + text = f"<{host}:{port}>: MinerCommandTimeoutError, ConnectionRef" assert str(exception)[: len(text)] == text # not miner on a wrong port From 8b8ed2c0aea8be58ed12dcc252a84f2d948b980c Mon Sep 17 00:00:00 2001 From: antonio Date: Wed, 13 Nov 2024 20:46:50 -0500 Subject: [PATCH 04/16] move out of the v1 module ArgumentTypeBase --- src/luxos/cli/v1.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/luxos/cli/v1.py b/src/luxos/cli/v1.py index 68d0fc5..a5900ee 100644 --- a/src/luxos/cli/v1.py +++ b/src/luxos/cli/v1.py @@ -102,7 +102,7 @@ def main(parser: argparse.ArgumentParser): from typing import Any, Callable from . import flags -from .shared import ArgumentTypeBase, LuxosParserBase +from .shared import LuxosParserBase class MyHandler(logging.StreamHandler): @@ -175,10 +175,6 @@ def parse_args(self, args=None, namespace=None): options.error = self.error options.modules = self.modules - for name in dir(options): - if isinstance(getattr(options, name), ArgumentTypeBase): - setattr(options, name, getattr(options, name).value) - for callback in self.callbacks: if not callback: continue From e786fe1d6b95e09f10bc6f3577119f0b5b442b05 Mon Sep 17 00:00:00 2001 From: antonio Date: Wed, 13 Nov 2024 20:48:35 -0500 Subject: [PATCH 05/16] rewamp the shared cli module --- src/luxos/cli/shared.py | 60 ++++++++++-- tests/test_cli_shared.py | 195 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 tests/test_cli_shared.py diff --git a/src/luxos/cli/shared.py b/src/luxos/cli/shared.py index 35cd25f..ab3e6fd 100644 --- a/src/luxos/cli/shared.py +++ b/src/luxos/cli/shared.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import inspect import sys import types from typing import Callable @@ -13,6 +14,15 @@ ArgsCallback = Callable +def check_default_constructor(klass: type): + signature = inspect.signature(klass.__init__) # type: ignore[misc] + for name, value in signature.parameters.items(): + if name in {"self", "args", "kwargs"}: + continue + if value.default is inspect.Signature.empty: + raise RuntimeError(f"the {klass}() cannot be called without arguments") + + # The luxor base parser class LuxosParserBase(argparse.ArgumentParser): def __init__(self, modules: list[types.ModuleType], *args, **kwargs): @@ -20,13 +30,30 @@ def __init__(self, modules: list[types.ModuleType], *args, **kwargs): self.modules = modules self.callbacks: list[ArgsCallback | None] = [] + def parse_args(self, args=None, namespace=None): + options = super().parse_args(args, namespace) + for name in dir(options): + if isinstance(getattr(options, name), ArgumentTypeBase): + fallback = getattr(options, name).value + setattr( + options, + name, + None if fallback is ArgumentTypeBase._NA else fallback, + ) + return options + def add_argument(self, *args, **kwargs): - try: - if issubclass(kwargs.get("type"), ArgumentTypeBase): - kwargs["default"] = kwargs.get("type")(kwargs.get("default")) - kwargs["type"] = kwargs["default"] - except TypeError: - pass + typ = kwargs.get("type") + obj = None + if isinstance(typ, type) and issubclass(typ, ArgumentTypeBase): + check_default_constructor(typ) + obj = typ() + if isinstance(typ, ArgumentTypeBase): + obj = typ + if obj is not None: + obj.default = kwargs.get("default", ArgumentTypeBase._NA) + kwargs["default"] = obj + kwargs["type"] = obj super().add_argument(*args, **kwargs) @@ -34,10 +61,23 @@ class ArgumentTypeBase: class _NA: pass - def __init__(self, default=_NA): - self.default = default - if default is not ArgumentTypeBase._NA: - self.default = self._validate(default) + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self._default = self._NA + self.__name__ = self.__class__.__name__ + + @property + def default(self): + return self._default + + @default.setter + def default(self, value): + if value is ArgumentTypeBase._NA: + self._default = ArgumentTypeBase._NA + else: + self._default = self._validate(value) + return self._default def __call__(self, txt): self._value = None diff --git a/tests/test_cli_shared.py b/tests/test_cli_shared.py new file mode 100644 index 0000000..1ca5ef6 --- /dev/null +++ b/tests/test_cli_shared.py @@ -0,0 +1,195 @@ +import argparse + +import pytest + +from luxos.cli import shared + + +class Flag(shared.ArgumentTypeBase): + def __init__(self, only_odd=False): + self.only_odd = only_odd + super().__init__() + + def validate(self, txt): + value = int(txt) + if self.only_odd: + if (value % 2) == 0: + raise argparse.ArgumentTypeError(f"non odd value '{txt}'") + return value + + +def test_check_default_constructor(): + """verifies the class constructor requires no arguments""" + + class A: + def __init__(self, value): + pass + + pytest.raises(RuntimeError, shared.check_default_constructor, A) + + class A: + def __init__(self, value=1): + pass + + assert shared.check_default_constructor(A) is None + + +def test_add_argument(): + """test all failures mode for add_argument default""" + p = shared.LuxosParserBase([], exit_on_error=False) + + with pytest.raises(ValueError) as e: + p.add_argument("--flag", type=Flag, default="yyy") + assert e.value.args[0] == "invalid literal for int() with base 10: 'yyy'" + + with pytest.raises(RuntimeError) as e: + p.add_argument("--flag", type=Flag(only_odd=True), default="124") + assert e.value.args[0] == "cannot use value='124' as default: non odd value '124'" + + with pytest.raises(RuntimeError) as e: + p.add_argument("--flag", type=Flag(only_odd=True), default=124) + assert e.value.args[0] == "cannot use value=124 as default: non odd value '124'" + + with pytest.raises(RuntimeError) as e: + p.add_argument("--flag", type=Flag(only_odd=True), default=126) + assert e.value.args[0] == "cannot use value=126 as default: non odd value '126'" + + with pytest.raises(RuntimeError) as e: + p.add_argument("--flag", type=Flag(only_odd=True), default="126") + assert e.value.args[0] == "cannot use value='126' as default: non odd value '126'" + + p.add_argument("--flag", type=Flag) + assert ( + len( + action := [ + a for a in p._actions if isinstance(a.type, shared.ArgumentTypeBase) + ] + ) + == 1 + ) + assert action[0].default.default is shared.ArgumentTypeBase._NA + + p.add_argument("--flag1", type=Flag, default="124") + assert ( + len( + actions := [ + a for a in p._actions if isinstance(a.type, shared.ArgumentTypeBase) + ] + ) + == 2 + ) + assert actions[1].default.default == 124 + + p.add_argument("--flag2", type=Flag, default=126) + assert ( + len( + actions := [ + a for a in p._actions if isinstance(a.type, shared.ArgumentTypeBase) + ] + ) + == 3 + ) + assert actions[2].default.default == 126 + + +def test_special_flag_no_restriction(): + """parse arguments with no default and no constrain on Flag""" + p = shared.LuxosParserBase([], exit_on_error=False) + p.add_argument("--flag", type=Flag) + + a = p.parse_args([]) + assert a.flag is None + + a = p.parse_args( + [ + "--flag", + "123", + ] + ) + assert a.flag == 123 + + a = p.parse_args( + [ + "--flag", + "124", + ] + ) + assert a.flag == 124 + + with pytest.raises(argparse.ArgumentError) as e: + p.parse_args(["--flag", "boo"]) + assert e.value.args[-1] == "invalid Flag value: 'boo'" + + # same as above but with a default fallback + p = shared.LuxosParserBase([], exit_on_error=False) + p.add_argument("--flag", type=Flag, default="42") + + a = p.parse_args([]) + assert a.flag == 42 + + a = p.parse_args( + [ + "--flag", + "123", + ] + ) + assert a.flag == 123 + + a = p.parse_args( + [ + "--flag", + "124", + ] + ) + assert a.flag == 124 + + with pytest.raises(argparse.ArgumentError) as e: + p.parse_args(["--flag", "boo"]) + assert e.value.args[-1] == "invalid Flag value: 'boo'" + + +def test_special_flag_with_restriction_no_default(): + p = shared.LuxosParserBase([], exit_on_error=False) + p.add_argument("--flag", type=Flag(only_odd=True)) + + a = p.parse_args([]) + assert a.flag is None + + a = p.parse_args( + [ + "--flag", + "123", + ] + ) + assert a.flag == 123 + + with pytest.raises(argparse.ArgumentError) as e: + p.parse_args(["--flag", "122"]) + assert e.value.args[-1] == "non odd value '122'" + + with pytest.raises(argparse.ArgumentError) as e: + p.parse_args(["--flag", "boo"]) + assert e.value.args[-1] == "invalid Flag value: 'boo'" + + # same as above but with a default fallback + p = shared.LuxosParserBase([], exit_on_error=False) + p.add_argument("--flag", type=Flag(only_odd=True), default=43) + + a = p.parse_args([]) + assert a.flag == 43 + + a = p.parse_args( + [ + "--flag", + "123", + ] + ) + assert a.flag == 123 + + with pytest.raises(argparse.ArgumentError) as e: + p.parse_args(["--flag", "122"]) + assert e.value.args[-1] == "non odd value '122'" + + with pytest.raises(argparse.ArgumentError) as e: + p.parse_args(["--flag", "boo"]) + assert e.value.args[-1] == "invalid Flag value: 'boo'" From cc4528b0f49b545b0e6f0d47888c33f0174bfd96 Mon Sep 17 00:00:00 2001 From: antonio Date: Wed, 13 Nov 2024 22:31:37 -0500 Subject: [PATCH 06/16] fix bug while raising error --- src/luxos/ips.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/luxos/ips.py b/src/luxos/ips.py index d61937e..3dd5f3e 100644 --- a/src/luxos/ips.py +++ b/src/luxos/ips.py @@ -23,7 +23,7 @@ def splitip(txt: str, strict=True) -> tuple[str, int | None]: try: port = int(port_txt) if port_txt else None except ValueError: - raise ValueError(f"cannot convert '{port}' to an integer") + raise ValueError(f"cannot convert '{port_txt}' to an integer") if not host.strip(): raise ValueError(f"cannot find host part in '{txt}'") From 89f5cc4c416dad5b25ea55af10989a52961b1b4e Mon Sep 17 00:00:00 2001 From: antonio Date: Wed, 13 Nov 2024 22:34:18 -0500 Subject: [PATCH 07/16] update flags to support strict/port options --- src/luxos/cli/flags.py | 26 ++++++++++++++++++-------- tests/conftest.py | 15 +++++++++++++++ tests/test_cli_flags.py | 17 +++++++---------- 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/luxos/cli/flags.py b/src/luxos/cli/flags.py index 79179a1..b9c9459 100644 --- a/src/luxos/cli/flags.py +++ b/src/luxos/cli/flags.py @@ -45,26 +45,36 @@ class type_ipaddress(ArgumentTypeBase): options = parser.parse_args() ... - assert options.x == ("host", 9999) + assert options.x == ("1.2.3.4", 9999) shell:: - file.py -x host:9999 + file.py -x 1.2.3.4:9999 """ + def __init__(self, port=None, strict=True): + super().__init__() + self.strict = strict + self.port = port + def validate(self, txt) -> None | tuple[str, None | int]: from luxos import ips if txt is None: return None try: - result = ips.parse_expr(txt) or ("", "", None) - if result[1]: - raise argparse.ArgumentTypeError("cannot use a range as expression") - return (result[0], result[2]) - except ips.AddressParsingError as exc: - raise argparse.ArgumentTypeError(f"failed to parse {txt=}: {exc.args[0]}") + if txt.count(":") not in {0, 1}: + raise ValueError("too many ':' (none or one)") + if self.strict: + ip, port = ips.splitip(txt) or ("", self.port) + else: + ip, _, port = txt.partition(":") + return ip, int(port) if port else self.port + except (RuntimeError, ValueError) as exc: + raise argparse.ArgumentTypeError( + f"failed to convert to a strict ip address: {exc.args[0]}" + ) def type_range(txt: str) -> Sequence[tuple[str, int | None]]: diff --git a/tests/conftest.py b/tests/conftest.py index 2f56010..581107f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -157,6 +157,21 @@ def port(miner_host_port): return miner_host_port[1] +@pytest.fixture(scope="function") +def cli_generator(): + from luxos.cli.shared import LuxosParserBase + + class MyParser(LuxosParserBase): + def __init__(self): + super().__init__([], exit_on_error=False) + + def add_argument(self, *args, **kwargs): + super().add_argument(*args, **kwargs) + return self + + return lambda: MyParser() + + def pytest_addoption(parser): parser.addoption( "--manual", diff --git a/tests/test_cli_flags.py b/tests/test_cli_flags.py index bce4c7d..09402fc 100644 --- a/tests/test_cli_flags.py +++ b/tests/test_cli_flags.py @@ -42,15 +42,12 @@ def test_type_range(resolver): def test_type_hhmm(): - assert flags.type_hhmm("12:34").default == datetime.time(12, 34) + assert flags.type_hhmm().validate("12:34") == datetime.time(12, 34) - pytest.raises(RuntimeError, flags.type_hhmm, "12") - pytest.raises(argparse.ArgumentTypeError, flags.type_hhmm(), "12") + with pytest.raises(argparse.ArgumentTypeError) as e: + flags.type_hhmm().validate("12") + assert e.value.args[-1] == "failed conversion into HH:MM for '12'" - -def test_type_ipaddress(): - assert flags.type_ipaddress("hello").default == ("hello", None) - assert flags.type_ipaddress("hello:123").default == ("hello", 123) - - pytest.raises(RuntimeError, flags.type_ipaddress, "12:dwedwe") - pytest.raises(argparse.ArgumentTypeError, flags.type_ipaddress(), "12:dwedwe") + with pytest.raises(argparse.ArgumentTypeError) as e: + flags.type_hhmm().validate("hello") + assert e.value.args[-1] == "failed conversion into HH:MM for 'hello'" From b57f5411b21973f33c4d481a13a529cf978c2fd1 Mon Sep 17 00:00:00 2001 From: antonio Date: Thu, 14 Nov 2024 17:14:13 -0500 Subject: [PATCH 08/16] quite the output, and fix callback for launch --- src/luxos/cli/v1.py | 4 ++-- src/luxos/utils.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/luxos/cli/v1.py b/src/luxos/cli/v1.py index a5900ee..6730a9f 100644 --- a/src/luxos/cli/v1.py +++ b/src/luxos/cli/v1.py @@ -139,7 +139,7 @@ class AbortWrongArgumentError(CliBaseError): def log_sys_info(modules=None): from luxos.version import get_version - log.info(get_version()) + log.debug(get_version()) log.debug("interpreter: %s", sys.executable) @@ -269,7 +269,7 @@ def setup( finally: if show_timing: delta = round(time.monotonic() - t0, 2) - log.info("task %s in %.2fs", success, delta) + log.debug("task %s in %.2fs", success, delta) if errormsg: parser.error(errormsg) diff --git a/src/luxos/utils.py b/src/luxos/utils.py index 42cf25c..174ca55 100644 --- a/src/luxos/utils.py +++ b/src/luxos/utils.py @@ -94,6 +94,9 @@ async def _fn(host: str, port: int): tback = "".join(traceback.format_exc()) brief = repr(exc.__context__ or exc.__cause__) out = LuxosLaunchError(host, port, traceback=tback, brief=brief) + finally: + if callback and not batch: + callback([(host, port)]) return out return _fn @@ -105,7 +108,7 @@ async def _fn(host: str, port: int): tasks = [call(*address) for address in subaddresses] result.extend(await asyncio.gather(*tasks, return_exceptions=True)) if callback: - callback(result) + callback(subaddresses) return result else: tasks = [call(*address) for address in addresses] From 7a2c64a2456bf025586f6a656f754316313916c8 Mon Sep 17 00:00:00 2001 From: antonio Date: Thu, 14 Nov 2024 17:24:31 -0500 Subject: [PATCH 09/16] refactor luxor-run and adds support for tqdm --- pyproject.toml | 2 +- src/luxos/scripts/luxos_run.py | 108 +++++++++++++++++++++++---------- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2d997b2..fe2cb1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ classifiers = [ dependencies = [ "pyyaml", + "tqdm", ] [project.optional-dependencies] @@ -36,7 +37,6 @@ extra = [ "asyncpg", "httpx", "pandas", - "tqdm", ] [project.urls] diff --git a/src/luxos/scripts/luxos_run.py b/src/luxos/scripts/luxos_run.py index e99b5aa..cda869a 100644 --- a/src/luxos/scripts/luxos_run.py +++ b/src/luxos/scripts/luxos_run.py @@ -7,6 +7,7 @@ # my-script.py from luxos import asyncops + async def main(host: str, port: int): res = await asyncops.rexec(host, port, "version") return asyncops.validate(res, "VERSION", 1, 1) @@ -24,12 +25,15 @@ async def main(host: str, port: int): import argparse import asyncio +import functools import inspect import json import logging import pickle -import sys from pathlib import Path +from typing import Any, Callable + +import tqdm from luxos import misc, text, utils @@ -42,16 +46,16 @@ def add_arguments(parser: cli.LuxosParserBase) -> None: cli.flags.add_arguments_new_miners_ips(parser) cli.flags.add_arguments_rexec(parser) parser.add_argument("script", type=Path, help="python script to run") + parser.add_argument("parameters", nargs="*") + + parser.add_argument("-s", "--setup", help="script setup up function (non async)") parser.add_argument( - "-e", - "--entry-point", - dest="entrypoint", - help="script entry point", - default="main", + "-e", "--entry-point", dest="entrypoint", help="script entry point" ) parser.add_argument( - "-t", "--teardown", dest="teardown", help="script tear down function" + "-t", "--teardown", help="script tear down function (non async)" ) + parser.add_argument("-n", "--batch", type=int, help="limit parallel executions") parser.add_argument( "--list", action="store_true", help="just display the machine to run script" @@ -67,6 +71,16 @@ def process_args(args: argparse.Namespace): args.error("need a miners flag (eg. --range/--ipfile)") if not args.script.exists(): args.error(f"missings script file {args.script}") + if args.script.suffix.upper() not in {".PY"}: + args.error(f"script must end with .py: {args.script}") + + +def apply_magic_arguments(fn: Callable, magic: dict[str, Any]) -> Callable: + sig = inspect.signature(fn) + arguments = sig.bind_partial( + **{k: v for k, v in magic.items() if k in sig.parameters} + ) + return functools.partial(fn, *arguments.args, **arguments.kwargs) @cli.cli(add_arguments=add_arguments, process_args=process_args) @@ -76,34 +90,66 @@ async def main(args: argparse.Namespace): print(address) return - # prepend the script dir to pypath - log.debug("inserting %s in PYTHONPATH", args.script.parent) - sys.path.insert(0, str(args.script.parent)) - + # load the module module = misc.loadmod(args.script) - entrypoint = getattr(module, args.entrypoint, None) - if not entrypoint: - args.error(f"no entry point {args.entrypoint} in {args.script}") - return + # assign the setup/entrypoint/teardown functions + setup = None + if args.setup != "": + setup = getattr(module, args.setup or "setup", None) + if args.setup and not setup: + args.error(f"no setup function {args.setup} in {args.script}") + log.debug( + "%susing setup function%s", + "" if setup else "not ", + f" {setup}" if setup else "", + ) + + if not (entrypoint := getattr(module, args.entrypoint or "main", None)): + args.error(f"no entry point {args.entrypoint or 'main'} in {args.script}") + if not inspect.iscoroutinefunction(entrypoint): + args.error( + f"entry point is not an async function: {args.entrypoint} in {args.script}" + ) + + if set(inspect.signature(entrypoint).parameters) - {"host", "port"}: # type: ignore + args.error( + f"entry point {args.entrypoint or 'main'} function " + "must have (host, port) signature" + ) + log.debug("using entrypoint function %s", entrypoint) teardown = None - if args.teardown == "": - pass - elif args.teardown: - if not hasattr(module, args.teardown): - args.error(f"no tear down function {args.teardown} in {args.script}") - teardown = getattr(module, args.teardown, None) - elif hasattr(module, "teardown"): - teardown = getattr(module, "teardown") + if args.teardown != "": + teardown = getattr(module, args.teardown or "teardown", None) + if args.teardown and not teardown: + args.error(f"no teardown function {args.teardown} in {args.script}") + log.debug( + "%susing teardown function%s", + "" if teardown else "not ", + f" {teardown}" if teardown else "", + ) - result = {} + # these are magic values passed to all setup/main/teardown functions + magic = {"addresses": args.addresses, "parameters": args.parameters, "result": {}} - def callback(result): - log.info("processed %i / %i", len(result), len(args.addresses)) + # ok, execute here + if setup: + apply_magic_arguments(setup, magic)() + + progress = tqdm.tqdm(total=len(args.addresses)) + + def callback(addresses): + progress.update(len(addresses)) + + result = magic["result"] for data in await utils.launch( - args.addresses, entrypoint, batch=args.batch, asobj=True, callback=callback + args.addresses, + entrypoint, # type: ignore + batch=args.batch, + asobj=True, + callback=callback, ): if isinstance(data, utils.LuxosLaunchTimeoutError): log.warning( @@ -114,7 +160,7 @@ def callback(result): ) elif isinstance(data, utils.LuxosLaunchError): log.warning( - "insternal error from %s: %s\n%s", + "internal error from %s: %s\n%s", data.address, data.brief, text.indent(data.traceback or "", "| "), @@ -123,11 +169,7 @@ def callback(result): result[data.address] = data.data if teardown: - if "result" in inspect.signature(teardown).parameters: - newresult = teardown(result) - else: - newresult = teardown() - result = newresult or result + result = apply_magic_arguments(teardown, magic)() or result if args.json: print(json.dumps(result, indent=2)) From f3928b3357c2bc113225349f649a6ec31ec2b3e2 Mon Sep 17 00:00:00 2001 From: antonio Date: Thu, 14 Nov 2024 17:38:08 -0500 Subject: [PATCH 10/16] update changelog --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9adcec5..e889670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,25 @@ Please, use the format: --> +## [0.2.5] + +- luxor-run support for progress bar +- luxor-run support setup/teardown functions +- cli/shared improve ArgumentTypeBase for flags supporting constraints +- ips.splitip better support for address +- syncops support packets with 0x00 termination + + ## [0.2.4] - adds --version flag to luxos.cli scripts - luxos is a namespaced project - version information is now from luxos.version module +- classes derived from ArgumentTypeBase accpet constructor arguments +- luxor-run takes a setup/teardown pair +- syncops handles packet with 0x00 endings +- improve cli tests + ## [0.2.2] From 55849845dad4ed165629d2952b79dfae7af21b7f Mon Sep 17 00:00:00 2001 From: antonio Date: Thu, 14 Nov 2024 20:24:54 -0500 Subject: [PATCH 11/16] adds new test, adds support for remote snippets --- src/luxos/misc.py | 9 ++ src/luxos/scripts/luxos_run.py | 11 ++- tests/test_cli_flags_type_ipaddress.py | 115 +++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 tests/test_cli_flags_type_ipaddress.py diff --git a/src/luxos/misc.py b/src/luxos/misc.py index 1dffa6d..ad8e1dd 100644 --- a/src/luxos/misc.py +++ b/src/luxos/misc.py @@ -50,6 +50,15 @@ def iter_ip_ranges( def loadmod(path: Path) -> types.ModuleType: from importlib import util + from types import ModuleType + from urllib.parse import urlparse + from urllib.request import urlopen + + if urlparse(str(path)).scheme in {"http", "https"}: + urltxt = str(urlopen(str(path)).read(), encoding="utf-8") + mod = ModuleType(str(path).rpartition("/")[2]) + exec(urltxt, globals=mod.__dict__) + return mod spec = util.spec_from_file_location(Path(path).name, Path(path)) module = util.module_from_spec(spec) # type: ignore diff --git a/src/luxos/scripts/luxos_run.py b/src/luxos/scripts/luxos_run.py index cda869a..8085a13 100644 --- a/src/luxos/scripts/luxos_run.py +++ b/src/luxos/scripts/luxos_run.py @@ -30,6 +30,7 @@ async def main(host: str, port: int): import json import logging import pickle +import urllib.parse from pathlib import Path from typing import Any, Callable @@ -69,10 +70,12 @@ def add_arguments(parser: cli.LuxosParserBase) -> None: def process_args(args: argparse.Namespace): if not args.addresses: args.error("need a miners flag (eg. --range/--ipfile)") - if not args.script.exists(): - args.error(f"missings script file {args.script}") - if args.script.suffix.upper() not in {".PY"}: - args.error(f"script must end with .py: {args.script}") + + if urllib.parse.urlparse(str(args.script)).scheme not in {"http", "https"}: + if not args.script.exists(): + args.error(f"missings script file {args.script}") + if args.script.suffix.upper() not in {".PY"}: + args.error(f"script must end with .py: {args.script}") def apply_magic_arguments(fn: Callable, magic: dict[str, Any]) -> Callable: diff --git a/tests/test_cli_flags_type_ipaddress.py b/tests/test_cli_flags_type_ipaddress.py new file mode 100644 index 0000000..ba91ed9 --- /dev/null +++ b/tests/test_cli_flags_type_ipaddress.py @@ -0,0 +1,115 @@ +import argparse + +import pytest + +from luxos.cli import flags + + +def test_type_ipaddress_validate(): + assert flags.type_ipaddress(strict=False).validate("hello") == ("hello", None) + assert flags.type_ipaddress(strict=False).validate("hello:123") == ("hello", 123) + + with pytest.raises(argparse.ArgumentTypeError) as e: + flags.type_ipaddress(strict=True).validate("hello") + assert e.value.args[-1] == ( + "failed to convert to a strict ip address: " + "cannot convert 'hello' into an ipv4 address N.N.N.N" + ) + + with pytest.raises(argparse.ArgumentTypeError) as e: + flags.type_ipaddress(strict=True).validate("1.2.3.4:hello") + assert e.value.args[-1] == ( + "failed to convert to a strict ip address: " + "cannot convert 'hello' to an integer" + ) + + assert flags.type_ipaddress(strict=True).validate("1.2.3.4:567") == ("1.2.3.4", 567) + + +def test_type_ipaddress_no_args(cli_generator): + parser = cli_generator().add_argument("address", type=flags.type_ipaddress) + + with pytest.raises(argparse.ArgumentError) as e: + parser.parse_args([]) + assert e.value.args[-1] == "the following arguments are required: address" + + with pytest.raises(argparse.ArgumentError) as e: + parser.parse_args(["hello"]) + assert e.value.args[-1] == ( + "failed to convert to a strict ip address: " + "cannot convert 'hello' into an ipv4 address N.N.N.N" + ) + + with pytest.raises(argparse.ArgumentError) as e: + parser.parse_args(["1.2.3.4:booo"]) + assert e.value.args[-1] == ( + "failed to convert to a strict ip address: " + "cannot convert 'booo' to an integer" + ) + + assert parser.parse_args(["1.2.3.4"]).address == ("1.2.3.4", None) + assert parser.parse_args(["1.2.3.4:123"]).address == ("1.2.3.4", 123) + + +def test_type_ipaddress_no_args_default(cli_generator): + with pytest.raises(RuntimeError) as e: + cli_generator().add_argument( + "address", type=flags.type_ipaddress, default="xxx" + ) + assert e.value.args[-1] == ( + "cannot use value='xxx' as default: " + "failed to convert to a strict ip address: " + "cannot convert 'xxx' into an ipv4 address N.N.N.N" + ) + + with pytest.raises(RuntimeError) as e: + cli_generator().add_argument( + "address", type=flags.type_ipaddress, default="1.2.3.4:no" + ) + assert e.value.args[-1] == ( + "cannot use value='1.2.3.4:no' as default: " + "failed to convert to a strict ip address: " + "cannot convert 'no' to an integer" + ) + + parser = cli_generator().add_argument( + "address", type=flags.type_ipaddress, nargs="?", default="1.2.3.4" + ) + assert parser.parse_args([]).address == ("1.2.3.4", None) + + parser = cli_generator().add_argument( + "address", type=flags.type_ipaddress, nargs="?", default="1.2.3.4:123" + ) + assert parser.parse_args([]).address == ("1.2.3.4", 123) + + parser = cli_generator().add_argument( + "address", type=flags.type_ipaddress, nargs="?", default="1.2.3.4:123" + ) + assert parser.parse_args(["5.6.7.8"]).address == ("5.6.7.8", None) + + parser = cli_generator().add_argument( + "address", type=flags.type_ipaddress, nargs="?", default="1.2.3.4:123" + ) + assert parser.parse_args(["5.6.7.8:90"]).address == ("5.6.7.8", 90) + + +def test_type_ipaddress_with_args(cli_generator): + with pytest.raises(RuntimeError) as e: + cli_generator().add_argument( + "address", type=flags.type_ipaddress(strict=True), default="xxx" + ) + assert e.value.args[-1] == ( + "cannot use value='xxx' as default: " + "failed to convert to a strict ip address: " + "cannot convert 'xxx' into an ipv4 address N.N.N.N" + ) + cli_generator().add_argument( + "address", type=flags.type_ipaddress(strict=False), default="xxx" + ) + + parser = cli_generator().add_argument( + "address", type=flags.type_ipaddress(port=456), nargs="?", default="1.2.3.4" + ) + assert parser.parse_args([]).address == ("1.2.3.4", 456) + assert parser.parse_args(["5.6.7.8"]).address == ("5.6.7.8", 456) + assert parser.parse_args(["5.6.7.8:90"]).address == ("5.6.7.8", 90) From 40d7042bbd7354c03ddc8544f13d8e0c2f018df0 Mon Sep 17 00:00:00 2001 From: antonio Date: Thu, 14 Nov 2024 20:29:53 -0500 Subject: [PATCH 12/16] add tqdm dependency --- tests/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/requirements.txt b/tests/requirements.txt index 9d378f4..464efca 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,5 +1,6 @@ # base (from pyproject.toml) pyyaml +tqdm # all test deps build From 8b3c6245a2541ab0bf1db6e7704f977d392fca89 Mon Sep 17 00:00:00 2001 From: antonio Date: Thu, 14 Nov 2024 20:33:23 -0500 Subject: [PATCH 13/16] patch --- src/luxos/misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/luxos/misc.py b/src/luxos/misc.py index ad8e1dd..c8df90f 100644 --- a/src/luxos/misc.py +++ b/src/luxos/misc.py @@ -57,7 +57,7 @@ def loadmod(path: Path) -> types.ModuleType: if urlparse(str(path)).scheme in {"http", "https"}: urltxt = str(urlopen(str(path)).read(), encoding="utf-8") mod = ModuleType(str(path).rpartition("/")[2]) - exec(urltxt, globals=mod.__dict__) + exec(urltxt, mod.__dict__) return mod spec = util.spec_from_file_location(Path(path).name, Path(path)) From 25d247e933691bdd70332ca557391ea3b9765ebb Mon Sep 17 00:00:00 2001 From: Antonio Cavallo Date: Thu, 14 Nov 2024 23:17:27 -0500 Subject: [PATCH 14/16] fix --- src/luxos/cli/shared.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/luxos/cli/shared.py b/src/luxos/cli/shared.py index ab3e6fd..c34e1c4 100644 --- a/src/luxos/cli/shared.py +++ b/src/luxos/cli/shared.py @@ -56,6 +56,13 @@ def add_argument(self, *args, **kwargs): kwargs["type"] = obj super().add_argument(*args, **kwargs) + def error(self, message): + try: + super().error(message) + except SystemExit: + # gh-121018 + raise argparse.ArgumentError(None, message) + class ArgumentTypeBase: class _NA: From 9abe0ba00941854ce619672804ea7596964e1966 Mon Sep 17 00:00:00 2001 From: antonio Date: Thu, 14 Nov 2024 23:41:43 -0500 Subject: [PATCH 15/16] version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fe2cb1f..e4d7ec0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "luxos" -version = "0.2.4" +version = "0.2.5" description = "The all encompassing LuxOS python library." readme = "README.md" license = { text = "MIT" } # TODO I don't think this is a MIT?? From 089709582a7408621bb2005ebae53b386c5927a3 Mon Sep 17 00:00:00 2001 From: cav71 Date: Fri, 15 Nov 2024 13:14:31 -0500 Subject: [PATCH 16/16] Update README.md --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8d036df..f8f055d 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,14 @@ full documentation [here](https://luxorlabs.github.io/luxos-tooling). To install the latest version: ```bash + # latest stable (base or with all extensions) $> pip install -U luxos - - # to install the extra features $> pip install -U luxos[extra] + + # beta from git hub + $> pip install git+https://github.com/LuxorLabs/luxos-tooling ``` +Or pulling the latest published from [pypi](https://pypi.org/project/luxos/#history). You can check the version: ```bash