Skip to content

pyln-client: reimplement NodeVersion, simply. #8143

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
3 changes: 1 addition & 2 deletions contrib/pyln-client/pyln/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from .plugin import Plugin, monkey_patch, RpcException
from .gossmap import Gossmap, GossmapNode, GossmapChannel, GossmapHalfchannel, GossmapNodeId, LnFeatureBits
from .gossmapstats import GossmapStats
from .version import NodeVersion, VersionSpec
from .version import NodeVersion

__version__ = "25.02"

Expand All @@ -22,5 +22,4 @@
"LnFeatureBits",
"GossmapStats",
"NodeVersion",
"VersionSpec",
]
207 changes: 26 additions & 181 deletions contrib/pyln-client/pyln/client/version.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
from __future__ import annotations

from dataclasses import dataclass
from functools import total_ordering
import re
from typing import List, Optional, Protocol, runtime_checkable, Union


_MODDED_PATTERN = "[0-9a-f]+-modded"

from typing import List, Union

@total_ordering
@dataclass
class NodeVersion:
"""NodeVersion

Expand All @@ -30,195 +24,46 @@ class NodeVersion:
- `v23.11` < `v24.02`
The oldest version is the smallest
"""

version: str

def to_parts(self) -> List[_NodeVersionPart]:
parts = self.version[1:].split(".")
# If the first part contains a v we will ignore it
if not parts[0][0].isdigit():
parts[0] = parts[1:]

return [_NodeVersionPart.parse(p) for p in parts]

def strict_equal(self, other: NodeVersion) -> bool:
if not isinstance(other, NodeVersion):
raise TypeError(
"`other` is expected to be of type `NodeVersion` but is `{type(other)}`"
)
else:
return self.version == other.version
def __init__(self, version: str):
# e.g. v24.11-225-gda793e66b9
if version.startswith('v'):
version = version[1:]
version = version.split('-')[0]
parts = version.split('.')
# rc is considered "close enough"
if 'rc' in parts[-1]:
parts[-1] = parts[-1].split('rc')[0]

self.parts: int = []
for p in parts:
self.parts.append(int(p))

def __eq__(self, other: Union[NodeVersion, str]) -> bool:
if isinstance(other, str):
other = NodeVersion(other)
if not isinstance(other, NodeVersion):
return False

if self.strict_equal(other):
return True
elif re.match(_MODDED_PATTERN, self.version):
if len(self.parts) != len(other.parts):
return False
else:
self_parts = [p.num for p in self.to_parts()]
other_parts = [p.num for p in other.to_parts()]

if len(self_parts) != len(other_parts):
for a, b in zip(self.parts, other.parts):
if a != b:
return False

for ps, po in zip(self_parts, other_parts):
if ps != po:
return False
return True
return True

def __lt__(self, other: Union[NodeVersion, str]) -> bool:
if isinstance(other, str):
other = NodeVersion(other)
if not isinstance(other, NodeVersion):
return NotImplemented

# If we are in CI the version will by a hex ending on modded
# We will assume it is the latest version
if re.match(_MODDED_PATTERN, self.version):
return False
elif re.match(_MODDED_PATTERN, other.version):
return True
else:
self_parts = [p.num for p in self.to_parts()]
other_parts = [p.num for p in other.to_parts()]

# zip truncates to shortes length
for sp, op in zip(self_parts, other_parts):
if sp < op:
return True
if sp > op:
return False

# If the initial parts are all equal the longest version is the biggest
#
# self = 'v24.02'
# other = 'v24.02.1'
return len(self_parts) < len(other_parts)

def matches(self, version_spec: VersionSpecLike) -> bool:
"""Returns True if the version matches the spec

The `version_spec` can be represented as a string and has 8 operators
which are `=`, `===`, `!=`, `!===`, `<`, `<=`, `>`, `>=`.

The `=` is the equality operator. The verson_spec `=v24.02` matches
all versions that equal `v24.02` including release candidates such as `v24.02rc1`.
You can use the strict-equality operator `===` if strict equality is required.

Specifiers can be combined by separating the with a comma ','. The `version_spec`
`>=v23.11, <v24.02" includes any version which is greater than or equal to `v23.11`
and smaller than `v24.02`.
"""
spec = VersionSpec.parse(version_spec)
return spec.matches(self)


@dataclass
class _NodeVersionPart:
num: int
text: Optional[str] = None

@classmethod
def parse(cls, part: str) -> _NodeVersionPart:
# We assume all parts start with a number and are followed by a text
# E.g: v24.01rc2 has two parts
# - "24" -> num = 24, text = None
# - "01rc" -> num = 01, text = "rc"

number = re.search(r"\d+", part).group()
text = part[len(number):]
text_opt = text if text != "" else None
return _NodeVersionPart(int(number), text_opt)


@runtime_checkable
class VersionSpec(Protocol):
def matches(self, other: NodeVersionLike) -> bool:
...

@classmethod
def parse(cls, spec: VersionSpecLike) -> VersionSpec:
if isinstance(spec, VersionSpec):
return spec
else:
parts = [p.strip() for p in spec.split(",")]
subspecs = [_CompareSpec.parse(p) for p in parts]
return _AndVersionSpecifier(subspecs)


@dataclass
class _AndVersionSpecifier(VersionSpec):
specs: List[VersionSpec]

def matches(self, other: NodeVersionLike) -> bool:
for spec in self.specs:
if not spec.matches(other):
# We want a zero-padded zip. Pad both to make one.
totlen = max(len(self.parts), len(other.parts))
for a, b in zip(self.parts + [0] * totlen, other.parts + [0] * totlen):
if a < b:
return True
if a > b:
return False
return True


_OPERATORS = [
"===", # Strictly equal
"!===", # not strictly equal
"=", # Equal
">=", # Greater or equal
"<=", # Less or equal
"<", # less
">", # greater than
"!=", # not equal
]


@dataclass
class _CompareSpec(VersionSpec):
operator: str
version: NodeVersion

def __post_init__(self):
if self.operator not in _OPERATORS:
raise ValueError(f"Invalid operator '{self.operator}'")

def matches(self, other: NodeVersionLike):
if isinstance(other, str):
other = NodeVersion(other)
if self.operator == "===":
return other.strict_equal(self.version)
if self.operator == "!===":
return not other.strict_equal(self.version)
if self.operator == "=":
return other == self.version
if self.operator == ">=":
return other >= self.version
if self.operator == "<=":
return other <= self.version
if self.operator == "<":
return other < self.version
if self.operator == ">":
return other > self.version
if self.operator == "!=":
return other != self.version
else:
ValueError("Unknown operator")

@classmethod
def parse(cls, spec_string: str) -> _CompareSpec:
spec_string = spec_string.strip()

for op in _OPERATORS:
if spec_string.startswith(op):
version = spec_string[len(op):]
version = version.strip()
return _CompareSpec(op, NodeVersion(version))

raise ValueError(f"Failed to parse '{spec_string}'")


NodeVersionLike = Union[NodeVersion, str]
VersionSpecLike = Union[VersionSpec, str]
return False

__all__ = [NodeVersion, NodeVersionLike, VersionSpec, VersionSpecLike]
__all__ = [NodeVersion]
71 changes: 11 additions & 60 deletions contrib/pyln-client/tests/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,90 +3,41 @@

def test_create_version():
# These are the strings returned by `lightningd --version`
_ = NodeVersion("v24.02")
_ = NodeVersion("23.08.1")


def test_parse_parts():
assert _NodeVersionPart.parse("2rc2") == _NodeVersionPart(2, "rc2")
assert _NodeVersionPart.parse("0rc1") == _NodeVersionPart(0, "rc1")
assert _NodeVersionPart.parse("2") == _NodeVersionPart(2, None)
assert _NodeVersionPart.parse("2").text is None


def test_version_to_parts():

assert NodeVersion("v24.02rc1").to_parts() == [
_NodeVersionPart(24),
_NodeVersionPart(2, "rc1"),
]

assert NodeVersion("v24.02.1").to_parts() == [
_NodeVersionPart(24),
_NodeVersionPart(2),
_NodeVersionPart(1),
]
_ = NodeVersion("v24.11-232-g5a76c7a")
_ = NodeVersion("v24.11-225-gda793e6-modded")
_ = NodeVersion("v24.11")


def test_equality_classes_in_node_versions():
assert NodeVersion("v24.02") == NodeVersion("v24.02")
assert NodeVersion("v24.02") == NodeVersion("v24.02rc1")
assert NodeVersion("v24.02rc1") == NodeVersion("v24.02")
assert NodeVersion("v24.11-217-g77989b1-modded") == NodeVersion("v24.11")
assert NodeVersion("v24.02") == NodeVersion("24.02")
assert NodeVersion("v24.02-225") == NodeVersion("v24.02")

assert NodeVersion("v24.02") != NodeVersion("v24.02.1")
assert NodeVersion("v24.02rc1") != NodeVersion("v24.02.1")
assert NodeVersion("v23.10") != NodeVersion("v23.02")
assert NodeVersion("v24.02") != NodeVersion("v24.02rc1")
assert NodeVersion("v24.11-217-g77989b1-modded") == NodeVersion("v24.11")


def test_inequality_of_node_versions():
assert not NodeVersion("v24.02.1") > NodeVersion("v24.02.1")
assert NodeVersion("v24.02.1") > NodeVersion("v24.02")
assert NodeVersion("v24.02.1") > NodeVersion("v24.02rc1")
assert NodeVersion("v24.02.1") > NodeVersion("v23.05")
assert NodeVersion("v24.05") > NodeVersion("v24.02")

assert NodeVersion("v24.02.1") >= NodeVersion("v24.02.1")
assert NodeVersion("v24.02.1") >= NodeVersion("v24.02")
assert NodeVersion("v24.02.1") >= NodeVersion("v24.02rc1")
assert NodeVersion("v24.02.1") >= NodeVersion("v23.05")
assert NodeVersion("v24.05") >= NodeVersion("v24.02")

assert NodeVersion("v24.02.1") <= NodeVersion("v24.02.1")
assert not NodeVersion("v24.02.1") <= NodeVersion("v24.02")
assert not NodeVersion("v24.02.1") <= NodeVersion("v24.02rc1")
assert not NodeVersion("v24.02.1") <= NodeVersion("v23.05")
assert not NodeVersion("v24.05") <= NodeVersion("v24.02")

assert not NodeVersion("v24.02.1") < NodeVersion("v24.02.1")
assert not NodeVersion("v24.02.1") < NodeVersion("v24.02")
assert not NodeVersion("v24.02.1") < NodeVersion("v24.02rc1")
assert not NodeVersion("v24.02.1") < NodeVersion("v23.05")


def test_comparision_parse():
assert _CompareSpec.parse("===v24.02").operator == "==="
assert _CompareSpec.parse("=v24.02").operator == "="
assert _CompareSpec.parse("!===v24.02").operator == "!==="
assert _CompareSpec.parse("!=v24.02").operator == "!="
assert _CompareSpec.parse(">v24.02").operator == ">"
assert _CompareSpec.parse("<v24.02").operator == "<"
assert _CompareSpec.parse(">=v24.02").operator == ">="
assert _CompareSpec.parse("<=v24.02").operator == "<="


def test_compare_spec_from_string():
assert VersionSpec.parse("=v24.02").matches("v24.02rc1")
assert VersionSpec.parse("=v24.02").matches("v24.02")
assert not VersionSpec.parse("=v24.02").matches("v24.02.1")

# Yes, I use weird spaces here as a part of the test
list_spec = VersionSpec.parse(">= v24.02, !=== v24.02rc1")
assert list_spec.matches("v24.02")
assert list_spec.matches("v24.02.1")

assert not list_spec.matches("v24.02rc1")
assert not list_spec.matches("v23.11")


def test_ci_modded_version_is_always_latest():
v1 = NodeVersion("1a86e50-modded")

assert v1 > NodeVersion("v24.02")
assert not NodeVersion("v24.05") < NodeVersion("v24.02")
Loading
Loading