Skip to content

Commit

Permalink
hor 1204 adds support for ranges ip addresses (#28)
Browse files Browse the repository at this point in the history
* rebase to main

* add support for coverage in make.pyz

* create a flags examples

* fix mypy config

* pre-release changelog

* moved load_ips_from_csv definition in luxos.ips

* add few extra flags

* silence missing imports

* fix mypy

* fix mypy

* fix windows failing test

* report session info as debug instead info log level
  • Loading branch information
cav71 authored May 24, 2024
1 parent 37dd548 commit 9a947c5
Show file tree
Hide file tree
Showing 17 changed files with 328 additions and 45 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pull-python-luxos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
OUTDIR: build/qa-${{ matrix.python-version }}-${{ matrix.os}}
run: |
mypy src \
--no-incremental --xslt-html-report $OUTDIR/mypy
--no-incremental --no-warn-unused-ignores --xslt-html-report $OUTDIR/mypy
- name: Run Python checks
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/push-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
OUTDIR: build/qa-${{ matrix.python-version }}-${{ matrix.os}}
run: |
mypy src \
--no-incremental --xslt-html-report $OUTDIR/mypy
--no-incremental --no-warn-unused-ignores --xslt-html-report $OUTDIR/mypy
- name: Run Python checks
shell: bash
Expand Down
10 changes: 3 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,12 @@ Please, use the format:
-->

## [Unreleased]

- remove *pyz files
- update pyproject.toml with latest ruff settings
- apply ruff to the code base


## [0.0.8]

- added a new cli.flags module to handle range flags (eg. allowing 127.0.0.1-127.0.0.9 ranges)
- add luxos.utils.ip_ranges to list ip addresses from a text
- remove *pyz files
- update pyproject.toml with latest ruff settings and pplied ruff to the codebase


## [0.0.7]
Expand Down
38 changes: 37 additions & 1 deletion docs/api/examples/simple2.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
"""script to show how to add process arguments
Example:
$> python simple2
Got for x='0'
INFO:luxos.cli.v1:task completed in 0.00s
$> python simple2 -x 3 --range 127.0.0.1-127.0.0.3
Got for x='6'
127.0.0.1:None
127.0.0.2:None
127.0.0.3:None
INFO:luxos.cli.v1:task completed in 0.00s
"""

import argparse
import asyncio

Expand All @@ -7,15 +22,36 @@
def add_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument("-x", type=int, default=0, help="set the x flag")

# using the internal range flag
parser.add_argument(
"--range", action="append", type=cli.flags.type_range, help="add ranged hosts"
)

# adds rexec flags
cli.flags.add_arguments_rexec(parser)

# add a new time flag
parser.add_argument("--time", type=cli.flags.type_hhmm)


def process_args(args: argparse.Namespace) -> argparse.Namespace | None:
args.x = 2 * args.x

# we flatten all the addresses
args.range = [a for r in args.range or [] for a in r]


@cli.cli(add_arguments, process_args)
async def main(args: argparse.Namespace):
"""a simple test script with a simple description"""
print(f"Got for x='{args.x}'")

if args.range:
print("args.range")
for host, port in args.range or []:
print(f" {host}:{port}")
for key in ["x", "time", "timeout", "max_retries", "delay_retry"]:
if getattr(args, key, None) is not None:
print(f"args.{key}: {getattr(args, key)} ({type(getattr(args, key))})")


if __name__ == "__main__":
Expand Down
21 changes: 20 additions & 1 deletion make.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

log = logging.getLogger(__name__)

BUILDDIR = Path.cwd() / "build"
DOCDIR = Path.cwd() / "build" / "docs" # output doc dir


Expand Down Expand Up @@ -101,15 +102,33 @@ def check():
subprocess.check_call(["ruff", "check", "src", "tests"])


@task()
def fmt():
"""format and fix the code"""
subprocess.check_call(["ruff", "check", "--fix", "src", "tests"])
subprocess.check_call(["ruff", "format", "src", "tests"])


@task()
def tests():
"""runs all tests (excluding the manual ones)"""
workdir = Path.cwd()
env = os.environ.copy()
env["PYTHONPATH"] = str(Path.cwd() / "src")
subprocess.check_call(
["pytest", "-vvs", "--manual", str(workdir / "tests")], env=env
[
"pytest",
"-vvs",
"--manual",
"--cov",
"luxos",
"--cov-report",
f"html:{BUILDDIR / 'coverage'}",
str(workdir / "tests"),
],
env=env,
)
print(f"Coverage report under {BUILDDIR / 'coverage'}")


@task(name="beta-build")
Expand Down
2 changes: 1 addition & 1 deletion src/luxos/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def logon_required(cmd: str, commands_list=COMMANDS) -> bool | None:

if cmd not in COMMANDS:
log.info(
"%s command is not supported. " "Try again with a different command.", cmd
"%s command is not supported. Try again with a different command.", cmd
)
return None

Expand Down
8 changes: 4 additions & 4 deletions src/luxos/asyncops.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ async def execute_command(
if api.logon_required(cmd):
sid = await logon(host, port)
parameters = [sid, *parameters]
log.info("session id requested & obtained for %s:%i (%s)", host, port, sid)
log.debug("session id requested & obtained for %s:%i (%s)", host, port, sid)
else:
log.debug("no logon required for command '%s' on %s:%i", cmd, host, port)

Expand All @@ -226,7 +226,7 @@ async def execute_command(
await logoff(host, port, sid)


def _rexec_paramteres(
def _rexec_parameters(
parameters: str | list[Any] | dict[str, Any] | None = None,
) -> list[str]:
if isinstance(parameters, dict):
Expand Down Expand Up @@ -256,7 +256,7 @@ async def rexec(
) -> dict[str, Any] | None:
from . import api

parameters = _rexec_paramteres(parameters)
parameters = _rexec_parameters(parameters)

timeout = TIMEOUT if timeout is None else timeout
retry = RETRY if retry is None else retry
Expand Down Expand Up @@ -287,7 +287,7 @@ async def rexec(
try:
sid = await logon(host, port, timeout)
parameters = [sid, *parameters]
log.info("session id requested & obtained for %s:%i (%s)", host, port, sid)
log.debug("session id requested & obtained for %s:%i (%s)", host, port, sid)
break
except Exception as exc:
failure = exc
Expand Down
68 changes: 68 additions & 0 deletions src/luxos/cli/flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""various argparse `type` attributes"""

from __future__ import annotations

import argparse
import contextlib
import datetime


def type_range(txt: str) -> list[tuple[str, int | None]]:
"""type conversion for ranges
This will enforce conversion between a string and a ranged object.
Eg.
parser.add_argument("--range", type=type_range)
The --range argument will be:
127.0.0.1 # single ip address
127.0.0.1:1234 # single ip address with port
127.0.0.1-127.0.0.3 # a list of (ip, port) tuple between *.1 and.3
"""
from luxos.ips import iter_ip_ranges

try:
return list(iter_ip_ranges(txt))
except RuntimeError as exc:
raise argparse.ArgumentTypeError(f"conversion failed '{txt}': {exc.args[0]}")
except Exception as exc:
raise argparse.ArgumentTypeError(f"conversion failed for {txt}") from exc


def type_hhmm(txt: str):
"""type conversion for ranges
This will enforce conversion between a string and datetime.time object.
Eg.
parser.add_argument("--time", type=type_hhmm)
The --time format is HH:MM
"""
if not txt:
return
with contextlib.suppress(ValueError, TypeError):
hh, _, mm = txt.partition(":")
hh1 = int(hh)
mm1 = int(mm)
return datetime.time(hh1, mm1)
raise argparse.ArgumentTypeError(f"failed conversion into HH:MM for '{txt}'")


def add_arguments_rexec(parser: argparse.ArgumentParser) -> None:
group = parser.add_argument_group(
"Remote execution", "rexec remote execution limits/timeouts"
)
group.add_argument(
"--timeout", type=float, default=3.0, help="Timeout for each command"
)
group.add_argument(
"--max-retries",
type=int,
default=3,
help="Maximum number of retries for each command",
)
group.add_argument(
"--delay-retry", type=float, default=3.0, help="Delay in s between retries"
)
4 changes: 3 additions & 1 deletion src/luxos/cli/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ def main(parser: argparse.ArgumentParser):
from pathlib import Path
from typing import Any, Callable

from . import flags # noqa: F401

# SPECIAL MODULE LEVEL VARIABLES
MODULE_VARIABLES = {
"LOGGING_CONFIG": None, # logging config dict
Expand Down Expand Up @@ -286,7 +288,7 @@ def _cli2(*args, **kwargs):
log_sys_info()
return function(*ba.args, **ba.kwargs)

_cli2.attributes = {
_cli2.attributes = { # type: ignore[attr-defined]
"doc": function.__doc__ or module.__doc__ or "",
}
return _cli2
Expand Down
90 changes: 90 additions & 0 deletions src/luxos/ips.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""ipaddress manipulation"""

from __future__ import annotations

import ipaddress
import re
from pathlib import Path
from typing import Generator


def splitip(txt: str) -> tuple[str, int | None]:
expr = re.compile(r"(?P<ip>\d{1,3}([.]\d{1,3}){3})(:(?P<port>\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 iter_ip_ranges(
txt: str, port: int | None = None, rsep: str = "-", gsep: str = ","
) -> Generator[tuple[str, int | None], None, None]:
"""iterate over ip ranges.
The txt string cav have one of these formats:
1. a single ip such as '127.0.0.1' or '127.0.0.1:8080'
2. an (inclusive) range using two ips separated by `-`
as '127.0.0.1 - 127.0.0.3'
3. a combination of the above `,` separated as
'127.0.0.1 , 192.168.0.1-192.168.0.10'
Example:
```python
for ip in iter_ip_ranges("127.0.0.1 , 127.0.0.3-127.0.0.15"):
print(ip)
127.0.0.1
127.0.0.2
127.0.0.3
...
127.0.0.15
```
"""
for segment in txt.replace(" ", "").split(gsep):
start, _, end = segment.partition(rsep)
if not end:
start, sport = splitip(start)
yield (start, sport or port)
else:
start, sport = splitip(start)
end, eport = splitip(end)
if (sport and eport) and (sport != eport):
raise RuntimeError(f"invalid range ports in {segment}")
cur = ipaddress.IPv4Address(start)
last = ipaddress.IPv4Address(end)
theport = sport or eport or port
while cur <= last:
yield (str(cur), theport)
cur += 1


def load_ips_from_csv(path: Path | str, port: int = 4028) -> list[tuple[str, int]]:
"""loads ip addresses from a csv file
Example:
```python
foobar.csv contains ranges as parsed by iter_ip_ranges
127.0.0.1 # a single address
127.0.0.2-127.0.0.10
for ip in load_ips_from_csv("foobar.csv"):
print(ip)
(127.0.0.1, 4028)
(127.0.0.2, 4028)
(127.0.0.3, 4028)
...
(127.0.0.10, 4028)
```
"""
result = []
for line in Path(path).read_text().split("\n"):
line = line.partition("#")[0]
if not line.strip():
continue
for host, port2 in iter_ip_ranges(line):
result.append((host, port2 or port))
return result
Loading

0 comments on commit 9a947c5

Please sign in to comment.