From a786b6ec6726524a2a328d715fe67a5f6354e97a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Wed, 28 Jan 2026 10:22:45 +0100 Subject: [PATCH 1/9] feat: upgrade to pydantic v2 pydantic-v2 --- README.md | 14 +- lnurl/__init__.py | 4 +- lnurl/cli.py | 4 +- lnurl/core.py | 23 ++- lnurl/models.py | 91 +++++---- lnurl/types.py | 373 +++++++++++++++++++++++------------- pyproject.toml | 4 +- tests/test_core.py | 5 +- tests/test_fast_withdraw.py | 5 +- tests/test_models.py | 122 ++++++------ tests/test_types.py | 90 ++++----- uv.lock | 125 ++++++++++-- 12 files changed, 532 insertions(+), 328 deletions(-) diff --git a/README.md b/README.md index 7ecb08f..12a3ba2 100755 --- a/README.md +++ b/README.md @@ -69,9 +69,8 @@ lnurl.bech32 # "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E5K7TELWY7NXENRXVMRGDTZXSENJCM98 lnurl.bech32.hrp # "lnurl" lnurl.url # "https://service.io/?q=3fc3645b439ce8e7" lnurl.url.host # "service.io" -lnurl.url.base # "https://service.io/" lnurl.url.query # "q=3fc3645b439ce8e7" -lnurl.url.query_params # {"q": "3fc3645b439ce8e7"} +dict(lnurl.url.query_params()) # {"q": "3fc3645b439ce8e7"} ``` Parsing LNURL responses @@ -94,8 +93,8 @@ try: res.ok # bool res.maxSendable # int res.max_sats # int - res.callback.base # str - res.callback.query_params # dict + res.callback.host # str + dict(res.callback.query_params()) # dict res.metadata # str res.metadata.list() # list res.metadata.text # str @@ -125,10 +124,10 @@ For LNURL services, the `lnurl` package can be used to build **valid** responses ```python from lnurl import CallbackUrl, LnurlWithdrawResponse, MilliSatoshi -from pydantic import parse_obj_as, ValidationError +from pydantic import TypeAdapter, ValidationError try: res = LnurlWithdrawResponse( - callback=parse_obj_as(CallbackUrl, "https://lnurl.bigsun.xyz/lnurl-withdraw/callback/9702808"), + callback=TypeAdapter(CallbackUrl).validate_python("https://lnurl.bigsun.xyz/lnurl-withdraw/callback/9702808"), k1="38d304051c1b76dcd8c5ee17ee15ff0ebc02090c0afbc6c98100adfa3f920874", minWithdrawable=MilliSatoshi(1000), maxWithdrawable=MilliSatoshi(1000000), @@ -144,8 +143,7 @@ All responses are `pydantic` models, so the information you provide will be vali access to `.json()` and `.dict()` methods to export the data. **Data is exported using :camel: camelCase keys by default, as per spec.** -You can also use camelCases when you parse the data, and it will be converted to snake_case to make your -Python code nicer. +Use the LNURL spec field names when parsing and exporting response models. Will throw and ValidationError if the data is not valid, so you can catch it and return an error response. diff --git a/lnurl/__init__.py b/lnurl/__init__.py index 4ad23e3..545f0d4 100644 --- a/lnurl/__init__.py +++ b/lnurl/__init__.py @@ -1,6 +1,3 @@ -# backward compatibility, MilliSatoshi is now imported from bolt11 -from bolt11 import MilliSatoshi - from .core import decode, encode, execute, execute_login, execute_pay_request, execute_withdraw, get, handle from .exceptions import ( InvalidLnurl, @@ -63,6 +60,7 @@ LnurlResponseTag, LnurlStatus, Max144Str, + MilliSatoshi, Url, ) diff --git a/lnurl/cli.py b/lnurl/cli.py index 0d1bdad..43573d7 100755 --- a/lnurl/cli.py +++ b/lnurl/cli.py @@ -48,7 +48,7 @@ def handle(lnurl): handle a LNURL """ decoded = asyncio.run(handle_lnurl(lnurl)) - click.echo(decoded.json()) + click.echo(decoded.model_dump_json()) @click.command() @@ -61,7 +61,7 @@ def execute(lnurl, msat_or_login): if not msat_or_login: raise ValueError("You must provide either an amount_msat or a login_id.") res = asyncio.run(execute_lnurl(lnurl, msat_or_login)) - click.echo(res.json()) + click.echo(res.model_dump_json()) def main(): diff --git a/lnurl/core.py b/lnurl/core.py index 815e4f3..cc23b4f 100644 --- a/lnurl/core.py +++ b/lnurl/core.py @@ -4,7 +4,7 @@ import httpx from bolt11 import Bolt11Exception, MilliSatoshi from bolt11 import decode as bolt11_decode -from pydantic import ValidationError, parse_obj_as +from pydantic import TypeAdapter, ValidationError from .exceptions import InvalidLnurl, InvalidUrl, LnurlResponseException from .helpers import ( @@ -80,16 +80,23 @@ async def handle( try: if "@" in lnurl: lnaddress = LnAddress(lnurl) - return await get(lnaddress.url, response_class=response_class, user_agent=user_agent, timeout=timeout) + return await get(str(lnaddress.url), response_class=response_class, user_agent=user_agent, timeout=timeout) lnurl = Lnurl(lnurl) except (ValidationError, ValueError): raise InvalidLnurl if lnurl.is_login: - callback_url = parse_obj_as(CallbackUrl, lnurl.url) - return LnurlAuthResponse(callback=callback_url, k1=lnurl.url.query_params["k1"]) + callback_url = TypeAdapter(CallbackUrl).validate_python(lnurl.url) + k1 = None + for param in lnurl.url.query_params(): + if param[0] == "k1": + k1 = param[1] + break + if not k1: + raise LnurlResponseException("k1 parameter not found in LNURLauth URL") + return LnurlAuthResponse(callback=callback_url, k1=k1) - return await get(lnurl.url, response_class=response_class, user_agent=user_agent, timeout=timeout) + return await get(str(lnurl.url), response_class=response_class, user_agent=user_agent, timeout=timeout) async def execute( @@ -134,7 +141,7 @@ async def execute_pay_request( headers = {"User-Agent": user_agent or USER_AGENT} async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: res2 = await client.get( - url=res.callback, + url=str(res.callback), params=params, timeout=timeout or TIMEOUT, ) @@ -178,7 +185,7 @@ async def execute_login( headers = {"User-Agent": user_agent or USER_AGENT} async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: res2 = await client.get( - url=res.callback, + url=str(res.callback), params={ "key": key, "sig": sig, @@ -209,7 +216,7 @@ async def execute_withdraw( headers = {"User-Agent": user_agent or USER_AGENT} async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: res2 = await client.get( - url=res.callback, + url=str(res.callback), params={ "k1": res.k1, "pr": pr, diff --git a/lnurl/models.py b/lnurl/models.py index 6e009ec..48a3529 100644 --- a/lnurl/models.py +++ b/lnurl/models.py @@ -4,8 +4,7 @@ from abc import ABC from typing import Optional, Union -from bolt11 import MilliSatoshi -from pydantic import BaseModel, Field, ValidationError, validator +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from .exceptions import LnurlResponseException from .types import ( @@ -14,22 +13,33 @@ InitializationVectorBase64, LightningInvoice, LightningNodeUri, - Lnurl, + # Lnurl, LnurlPayMetadata, LnurlPaySuccessActionTag, LnurlResponseTag, LnurlStatus, + Lud17PayLink, Max144Str, + MilliSatoshi, Url, ) -class LnurlPayRouteHop(BaseModel): +class LnurlBaseModel(BaseModel): + def dict(self, *args, **kwargs): + kwargs.setdefault("mode", "json") + return self.model_dump(*args, **kwargs) + + def json(self, *args, **kwargs): + return self.model_dump_json(*args, **kwargs) + + +class LnurlPayRouteHop(LnurlBaseModel): nodeId: str channelUpdate: str -class LnurlPaySuccessAction(BaseModel, ABC): +class LnurlPaySuccessAction(LnurlBaseModel, ABC): tag: LnurlPaySuccessActionTag @@ -52,29 +62,35 @@ class AesAction(LnurlPaySuccessAction): iv: InitializationVectorBase64 -class LnurlResponseModel(BaseModel): - - class Config: - use_enum_values = True - extra = "forbid" - - def dict(self, **kwargs): - kwargs["exclude_none"] = True - return super().dict(**kwargs) +class LnurlResponseModel(LnurlBaseModel): - def json(self, **kwargs): - kwargs["exclude_none"] = True - return super().json(**kwargs) + model_config = ConfigDict( + use_enum_values=True, + extra="forbid", + ) @property def ok(self) -> bool: return True + def model_dump(self, *args, **kwargs): + kwargs.setdefault("exclude_none", True) + return super().model_dump(*args, **kwargs) + + def model_dump_json(self, *args, **kwargs): + kwargs.setdefault("exclude_none", True) + return super().model_dump_json(*args, **kwargs) + class LnurlErrorResponse(LnurlResponseModel): status: LnurlStatus = LnurlStatus.error reason: str + model_config = ConfigDict( + use_enum_values=True, + extra="forbid", + ) + @property def error_msg(self) -> str: return self.reason @@ -119,7 +135,7 @@ class LnurlHostedChannelResponse(LnurlResponseModel): # LUD-18: Payer identity in payRequest protocol. -class LnurlPayResponsePayerDataOption(BaseModel): +class LnurlPayResponsePayerDataOption(LnurlBaseModel): mandatory: bool @@ -127,12 +143,12 @@ class LnurlPayResponsePayerDataOptionAuth(LnurlPayResponsePayerDataOption): k1: str -class LnurlPayResponsePayerDataExtra(BaseModel): +class LnurlPayResponsePayerDataExtra(LnurlBaseModel): name: str field: LnurlPayResponsePayerDataOption -class LnurlPayResponsePayerData(BaseModel): +class LnurlPayResponsePayerData(LnurlBaseModel): name: Optional[LnurlPayResponsePayerDataOption] = None pubkey: Optional[LnurlPayResponsePayerDataOption] = None identifier: Optional[LnurlPayResponsePayerDataOption] = None @@ -141,13 +157,13 @@ class LnurlPayResponsePayerData(BaseModel): extras: Optional[list[LnurlPayResponsePayerDataExtra]] = None -class LnurlPayerDataAuth(BaseModel): +class LnurlPayerDataAuth(LnurlBaseModel): key: str k1: str sig: str -class LnurlPayerData(BaseModel): +class LnurlPayerData(LnurlBaseModel): name: Optional[str] = None pubkey: Optional[str] = None identifier: Optional[str] = None @@ -172,11 +188,11 @@ class LnurlPayResponse(LnurlResponseModel): allowsNostr: Optional[bool] = None nostrPubkey: Optional[str] = None - @validator("maxSendable") - def max_less_than_min(cls, value, values): # noqa - if "minSendable" in values and value < values["minSendable"]: + @model_validator(mode="after") + def max_less_than_min(self): + if self.maxSendable < self.minSendable: raise ValueError("`maxSendable` cannot be less than `minSendable`.") - return value + return self @property def min_sats(self) -> int: @@ -214,27 +230,18 @@ class LnurlWithdrawResponse(LnurlResponseModel): balanceCheck: Optional[CallbackUrl] = None currentBalance: Optional[MilliSatoshi] = None # LUD-19: Pay link discoverable from withdraw link. - payLink: Optional[str] = None - - @validator("payLink", pre=True) - def paylink_must_be_lud17(cls, value: Optional[str] = None) -> str | None: - if not value: - return None - lnurl = Lnurl(value) - if lnurl.is_lud17 and lnurl.lud17_prefix == "lnurlp": - return value - raise ValueError("`payLink` must be a valid LUD17 URL (lnurlp://).") - - @validator("maxWithdrawable") - def max_less_than_min(cls, value, values): - if "minWithdrawable" in values and value < values["minWithdrawable"]: + payLink: Lud17PayLink | None = None + + @model_validator(mode="after") + def max_less_than_min(self): + if self.maxWithdrawable < self.minWithdrawable: raise ValueError("`maxWithdrawable` cannot be less than `minWithdrawable`.") - return value + return self # LUD-08: Fast withdrawRequest. @property def fast_withdraw_query(self) -> str: - return "&".join([f"{k}={v}" for k, v in self.dict().items()]) + return "&".join([f"{k}={v}" for k, v in self.model_dump().items()]) @property def min_sats(self) -> int: diff --git a/lnurl/types.py b/lnurl/types.py index cfe56e8..56ee9d9 100644 --- a/lnurl/types.py +++ b/lnurl/types.py @@ -3,39 +3,33 @@ import json import os import re +from decimal import Decimal from enum import Enum from hashlib import sha256 -from typing import Generator, Optional -from urllib.parse import parse_qs +from typing import Annotated, Any, Optional from pydantic import ( + AfterValidator, AnyUrl, - ConstrainedStr, + BeforeValidator, + GetCoreSchemaHandler, + HttpUrl, Json, + StringConstraints, + TypeAdapter, + UrlConstraints, ValidationError, - parse_obj_as, - validator, ) -from pydantic.validators import str_validator +from pydantic_core import core_schema from .exceptions import InvalidLnurlPayMetadata, InvalidUrl, LnAddressError from .helpers import _bech32_decode, _lnurl_clean, url_decode, url_encode INSECURE_HOSTS = ["127.0.0.1", "0.0.0.0", "localhost"] +LUD17_SCHEMES = ["lnurlc", "lnurlw", "lnurlp", "keyauth"] -class ReprMixin: - def __repr__(self) -> str: - attrs = [ # type: ignore - outer_slot # type: ignore - for outer_slot in [slot for slot in self.__slots__ if not slot.startswith("_")] # type: ignore - if getattr(self, outer_slot) # type: ignore - ] # type: ignore - extra = ", " + ", ".join(f"{n}={getattr(self, n).__repr__()}" for n in attrs) if attrs else "" - return f"{self.__class__.__name__}({super().__repr__()}{extra})" - - -class Bech32(ReprMixin, str): +class Bech32(str): """Bech32 string.""" __slots__ = ("hrp", "data") @@ -43,7 +37,7 @@ class Bech32(ReprMixin, str): def __new__(cls, bech32: str, **_) -> "Bech32": return str.__new__(cls, bech32) - def __init__(self, bech32: str, *, hrp: Optional[str] = None, data: Optional[list[int]] = None): + def __init__(self, bech32: str, hrp: Optional[str] = None, data: Optional[list[int]] = None): str.__init__(bech32) self.hrp, self.data = (hrp, data) if hrp and data else self.__get_data__(bech32) @@ -52,164 +46,237 @@ def __get_data__(cls, bech32: str) -> tuple[str, list[int]]: return _bech32_decode(bech32) @classmethod - def __get_validators__(cls): - yield str_validator - yield cls.validate - - @classmethod - def validate(cls, value: str) -> "Bech32": - hrp, data = cls.__get_data__(value) - return cls(value, hrp=hrp, data=data) + def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + + def validate(value: Any): + hrp, data = cls.__get_data__(str(value)) + value = cls(str(value), hrp=hrp, data=data) + return value + + def serialize(value: Any) -> str: + return str(value) + + return core_schema.no_info_after_validator_function( + validate, + core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + core_schema.str_schema(), + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema(serialize, when_used="json"), + ) -def ctrl_characters_validator(value: str) -> str: +def ctrl_characters_validator(value: AnyUrl) -> AnyUrl: """Checks for control characters (unicode blocks C0 and C1, plus DEL).""" - if re.compile(r"[\u0000-\u001f\u007f-\u009f]").search(value): + if re.compile(r"[\u0000-\u001f\u007f-\u009f]").search(str(value)): raise InvalidUrl("URL contains control characters.") return value -def strict_rfc3986_validator(value: str) -> str: +def strict_rfc3986_validator(value: AnyUrl) -> AnyUrl: """Checks for RFC3986 compliance.""" if os.environ.get("LNURL_STRICT_RFC3986", "0") == "1": - if re.compile(r"[^]a-zA-Z0-9._~:/?#[@!$&'()*+,;=-]").search(value): + if re.compile(r"[^]a-zA-Z0-9._~:/?#[@!$&'()*+,;=-]").search(str(value)): raise InvalidUrl("URL is not RFC3986 compliant.") return value -def valid_lnurl_host(url: str) -> AnyUrl: - """Validates the host part of a URL.""" - _url = parse_obj_as(AnyUrl, url) - if not _url.host: - raise InvalidUrl("URL host is required.") - if _url.scheme == "http": - if _url.host not in INSECURE_HOSTS and not _url.host.endswith(".onion"): - raise InvalidUrl("HTTP scheme is only allowed for localhost or onion addresses.") - return _url - - -class Url(AnyUrl): - max_length = 2047 # https://stackoverflow.com/questions/417142/ - - # LUD-17: Protocol schemes and raw (non bech32-encoded) URLs. - allowed_schemes = {"https", "http", "lnurlc", "lnurlw", "lnurlp", "keyauth"} - - @property - def is_lud17(self) -> bool: - uris = ["lnurlc", "lnurlw", "lnurlp", "keyauth"] - return any(self.scheme == uri for uri in uris) - - @classmethod - def __get_validators__(cls) -> Generator: - yield ctrl_characters_validator - yield strict_rfc3986_validator - yield valid_lnurl_host - yield cls.validate - - @property - def query_params(self) -> dict: - return {k: v[0] for k, v in parse_qs(self.query).items()} - - @property - def insecure(self) -> bool: - if not self.host: - return True - return self.scheme == "http" or self.host in INSECURE_HOSTS or self.host.endswith(".onion") - +def validate_http_host(url: AnyUrl) -> AnyUrl: + """Ensure only localhost or .onion addresses can use http://""" + if not url.host: + raise InvalidUrl("URL must have a valid host.") + if not url.scheme == "http" or url.host.endswith(".onion"): + return url + if url.host not in INSECURE_HOSTS: + raise InvalidUrl("HTTP scheme is only allowed for localhost or onion addresses.") + return url -class CallbackUrl(Url): - """URL for callbacks. exclude lud17 schemes.""" - allowed_schemes = {"https", "http"} +Url = Annotated[ + AnyUrl, + UrlConstraints( + max_length=2047, # https://stackoverflow.com/questions/417142/ + allowed_schemes=[ # LUD-17: Protocol schemes and raw (non bech32-encoded) URLs. + "https", + "http", + "lnurlc", + "lnurlw", + "lnurlp", + "keyauth", + ], + ), + BeforeValidator(ctrl_characters_validator), + BeforeValidator(strict_rfc3986_validator), + AfterValidator(validate_http_host), +] + +# URL for callbacks. exclude lud17 schemes. +CallbackUrl = Annotated[ + HttpUrl, + BeforeValidator(ctrl_characters_validator), + BeforeValidator(strict_rfc3986_validator), + AfterValidator(validate_http_host), +] + +# class Url(AnyUrl): +# max_length = 2047 # https://stackoverflow.com/questions/417142/ + +# # LUD-17: Protocol schemes and raw (non bech32-encoded) URLs. +# allowed_schemes = {"https", "http", "lnurlc", "lnurlw", "lnurlp", "keyauth"} + +# @property +# def is_lud17(self) -> bool: +# uris = ["lnurlc", "lnurlw", "lnurlp", "keyauth"] +# return any(self.scheme == uri for uri in uris) + +# @property +# def query_params(self) -> dict[str, str]: +# return {k: v[0] for k, v in parse_qs(self.query).items()} + +# @property +# def insecure(self) -> bool: +# if not self.host: +# return True +# return self.scheme == "http" or self.host in INSECURE_HOSTS or self.host.endswith(".onion") + +# @classmethod +# def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: +# return core_schema.chain_schema( +# [ +# core_schema.no_info_plain_validator_function(ctrl_characters_validator), +# core_schema.no_info_plain_validator_function(strict_rfc3986_validator), +# core_schema.no_info_plain_validator_function(validate_lnurl_host), +# ] +# ) class LightningInvoice(Bech32): """Bech32 Lightning invoice.""" -class LightningNodeUri(ReprMixin, str): +class LightningNodeUri(str): """Remote node address of form `node_key@ip_address:port_number`.""" - __slots__ = ("key", "ip", "port") + __slots__ = ("username", "host", "port") def __new__(cls, uri: str, **_) -> "LightningNodeUri": return str.__new__(cls, uri) - def __init__(self, uri: str, *, key: Optional[str] = None, ip: Optional[str] = None, port: Optional[str] = None): + def __init__( + self, + uri: str, + username: Optional[str] = None, + host: Optional[str] = None, + port: Optional[str] = None, + ): str.__init__(uri) - self.key = key - self.ip = ip - self.port = port + self.username, self.host, self.port = ( + (username, host, port) if username and host and port else self.__get_parts__(uri) + ) @classmethod - def __get_validators__(cls): - yield str_validator - yield cls.validate + def __get_parts__(cls, uri: str) -> tuple[str, str, str]: + match = re.fullmatch(r"(?P[^@:\s]+)@(?P[^@:\s]+):(?P[^@:\s]+)", uri) + if not match: + raise ValueError("Invalid Lightning node URI.") + return match.group("username"), match.group("host"), match.group("port") @classmethod - def validate(cls, value: str) -> "LightningNodeUri": - try: - key, netloc = value.split("@") - ip, port = netloc.split(":") - except Exception: - raise ValueError - - return cls(value, key=key, ip=ip, port=port) + def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + + def validate(value: Any): + username, host, port = cls.__get_parts__(str(value)) + return cls(str(value), username=username, host=host, port=port) + + def serialize(value: Any) -> str: + return str(value) + + return core_schema.no_info_after_validator_function( + validate, + core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + core_schema.str_schema(), + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema(serialize, when_used="json"), + ) -class Lnurl(ReprMixin, str): +class Lnurl(str): url: Url lud17_prefix: Optional[str] = None def __new__(cls, lightning: str) -> Lnurl: url = cls.clean(lightning) - _url = url.replace(url.scheme, "http" if url.insecure else "https", 1) - return str.__new__(cls, _url) + return str.__new__(cls, str(url)) def __init__(self, lightning: str): url = self.clean(lightning) - if not url.is_lud17: - self.url = url + if not url.host: + raise InvalidUrl("URL must have a valid host.") + is_lud17 = any(url.scheme == uri for uri in LUD17_SCHEMES) + if not is_lud17: + self.url = TypeAdapter(Url).validate_python(str(url)) self.lud17_prefix = None - return str.__init__(url) + return str.__init__(str(url)) self.lud17_prefix = url.scheme - _url = parse_obj_as(Url, url.replace(url.scheme, "http" if url.insecure else "https", 1)) - self.url = _url + insecure = url.host in INSECURE_HOSTS or url.host.endswith(".onion") + _replace = "http" if insecure else "https" + _url = str(url) + _url = _url.replace(url.scheme, _replace, 1) + self.url = TypeAdapter(Url).validate_python(_url) return str.__init__(_url) - @classmethod - def __get_validators__(cls): - yield str_validator - yield cls.validate - @classmethod def clean(cls, lightning: str) -> Url: lightning = _lnurl_clean(lightning) if lightning.lower().startswith("lnurl1"): - url = parse_obj_as(Url, url_decode(lightning)) + url = TypeAdapter(Url).validate_python(url_decode(lightning)) return url - url = parse_obj_as(Url, lightning) + url = TypeAdapter(Url).validate_python(lightning) return url @classmethod - def validate(cls, lightning: str) -> Lnurl: - _ = cls.clean(lightning) - return cls(lightning) + def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + + def validate(value: Any): + _ = cls.clean(value) + return cls(value) + + def serialize(value: Any) -> str: + return str(value) + + return core_schema.no_info_after_validator_function( + validate, + core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + core_schema.str_schema(), + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema(serialize, when_used="json"), + ) @property def bech32(self) -> Bech32: """Returns Bech32 representation of the Lnurl if it is a Bech32 encoded URL.""" - return parse_obj_as(Bech32, url_encode(self.url)) + url = url_encode(str(self.url)) + return TypeAdapter(Bech32).validate_python(url) # LUD-04: auth base spec. @property def is_login(self) -> bool: - return self.url.query_params.get("tag") == "login" + return any(k == "tag" and v == "login" for k, v in self.url.query_params()) # LUD-08: Fast withdrawRequest. @property def is_fast_withdraw(self) -> bool: - q = self.url.query_params + q: dict[str, str] = {} + for k, v in self.url.query_params(): + q[k] = v return ( q.get("tag") == "withdrawRequest" and q.get("k1") is not None @@ -228,11 +295,12 @@ def is_lud17(self) -> bool: def lud17(self) -> Optional[str]: if not self.lud17_prefix: return None - url = self.url.replace(self.url.scheme, self.lud17_prefix, 1) - return url + _url = str(self.url) + return _url.replace(self.url.scheme, self.lud17_prefix, 1) -class LnAddress(ReprMixin, str): +# LUD-16: Paying to static internet identifiers. +class LnAddress(str): """Lightning address of form `user+tag@host`""" slots = ("address", "url", "tag") @@ -252,9 +320,7 @@ def __init__(self, address: str): self.tag = None self.address = address - # LUD-16: Paying to static internet identifiers. - @validator("address") - def is_valid_lnaddress(cls, address: str) -> bool: + def is_valid_lnaddress(self, address: str) -> bool: # A user can then type these on a WALLET. The is limited # to a-z-1-9-_.. Please note that this is way more strict than common # email addresses as it allows fewer symbols and only lowercase characters. @@ -265,10 +331,10 @@ def is_valid_lnaddress(cls, address: str) -> bool: def __get_url__(cls, address: str) -> CallbackUrl: name, domain = address.split("@") url = ("http://" if domain.endswith(".onion") else "https://") + domain + "/.well-known/lnurlp/" + name - return parse_obj_as(CallbackUrl, url) + return TypeAdapter(CallbackUrl).validate_python(url) -class LnurlPayMetadata(ReprMixin, str): +class LnurlPayMetadata(str): # LUD-16: Paying to static internet identifiers. "text/identifier", "text/email", "text/tag" # LUD-20: Long payment description for pay protocol. "text/long-desc" valid_metadata_mime_types = { @@ -293,7 +359,7 @@ def __init__(self, json_str: str, *, json_obj: Optional[list] = None): @classmethod def __validate_metadata__(cls, json_str: str) -> list[tuple[str, str]]: try: - parse_obj_as(Json[list[tuple[str, str]]], json_str) + TypeAdapter(Json[list[tuple[str, str]]]).validate_python(json_str) data = [(str(item[0]), str(item[1])) for item in json.loads(json_str)] except ValidationError: raise InvalidLnurlPayMetadata @@ -312,13 +378,24 @@ def __validate_metadata__(cls, json_str: str) -> list[tuple[str, str]]: return clean_data @classmethod - def __get_validators__(cls): - yield str_validator - yield cls.validate - - @classmethod - def validate(cls, value: str) -> LnurlPayMetadata: - return cls(value, json_obj=cls.__validate_metadata__(value)) + def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + + def validate(value: Any): + return cls(value, json_obj=cls.__validate_metadata__(str(value))) + + def serialize(value: Any) -> str: + return str(value) + + return core_schema.no_info_after_validator_function( + validate, + core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + core_schema.str_schema(), + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema(serialize, when_used="json"), + ) @property def h(self) -> str: @@ -343,18 +420,32 @@ def list(self) -> list[tuple[str, str]]: return self._list -class InitializationVectorBase64(ConstrainedStr): - min_length = 24 - max_length = 24 +InitializationVectorBase64 = Annotated[str, StringConstraints(min_length=24, max_length=24)] +CiphertextBase64 = Annotated[str, StringConstraints(min_length=24, max_length=4096)] +Max144Str = Annotated[str, StringConstraints(max_length=144)] -class CiphertextBase64(ConstrainedStr): - min_length = 24 - max_length = 4096 +class MilliSatoshi(int): + """A thousandth of a satoshi.""" + @classmethod + def from_btc(cls, btc: Decimal) -> MilliSatoshi: + return cls(btc * 100_000_000_000) -class Max144Str(ConstrainedStr): - max_length = 144 + @property + def btc(self) -> Decimal: + return Decimal(self) / 100_000_000_000 + + @property + def sat(self) -> int: + return self // 1000 + + @classmethod + def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + return core_schema.no_info_after_validator_function( + cls, + core_schema.int_schema(), + ) # LUD-04: auth base spec. @@ -390,3 +481,15 @@ class LnurlResponseTag(Enum): hostedChannelRequest = "hostedChannelRequest" payRequest = "payRequest" withdrawRequest = "withdrawRequest" + + +def validate_paylink_is_lud17(value: Optional[str] = None) -> str | None: + if not value: + return None + lnurl = Lnurl(value) + if lnurl.is_lud17 and lnurl.lud17_prefix == "lnurlp": + return value + raise ValueError("`payLink` must be a valid LUD17 URL (lnurlp://).") + + +Lud17PayLink = Annotated[str, AfterValidator(validate_paylink_is_lud17)] diff --git a/pyproject.toml b/pyproject.toml index 44966d9..87592ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lnurl" -version = "0.10.0" +version = "2.0.0" requires-python = ">=3.10,<3.13" description = "LNURL implementation for Python." authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] @@ -8,7 +8,7 @@ urls = { Repository = "https://github.com/lnbits/lnurl" } readme = "README.md" license = "MIT" dependencies = [ - "pydantic>=1.10.0,<2.0.0", + "pydantic>=2.10.0,<3.0.0", "pycryptodomex>=3.21.0", "bip32>=5.0.0", "bech32", diff --git a/tests/test_core.py b/tests/test_core.py index 6faf12f..64953d0 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -11,7 +11,7 @@ LnurlSuccessResponse, LnurlWithdrawResponse, ) -from lnurl.types import Lnurl, Url +from lnurl.types import Lnurl class TestDecode: @@ -31,8 +31,7 @@ def test_decode(self, bech32, url): decoded = decode(bech32) assert isinstance(decoded, Lnurl) decoded_url = decoded.url - assert isinstance(decoded_url, Url) - assert decoded_url == str(decoded_url) == url + assert str(decoded_url) == url assert decoded_url.host == "service.io" @pytest.mark.parametrize( diff --git a/tests/test_fast_withdraw.py b/tests/test_fast_withdraw.py index afcca4c..3671f39 100644 --- a/tests/test_fast_withdraw.py +++ b/tests/test_fast_withdraw.py @@ -1,5 +1,5 @@ import pytest -from pydantic import parse_obj_as +from pydantic import TypeAdapter from lnurl import encode from lnurl.models import LnurlWithdrawResponse @@ -27,8 +27,7 @@ def test_is_lnurl_fast_withdraw(self, url: str, expected: bool): assert lnurl.is_fast_withdraw == expected def test_set_lnurl_fast_withdraw(self): - response = parse_obj_as( - LnurlWithdrawResponse, + response = TypeAdapter(LnurlWithdrawResponse).validate_python( { "tag": "withdrawRequest", "k1": "0" * 16, diff --git a/tests/test_models.py b/tests/test_models.py index 0d434e4..967493f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,13 +1,14 @@ import json import pytest -from pydantic import ValidationError, parse_obj_as +from pydantic import TypeAdapter, ValidationError from lnurl import CallbackUrl, Lnurl, LnurlPayMetadata, MilliSatoshi, encode from lnurl.models import ( LnurlChannelResponse, LnurlErrorResponse, LnurlHostedChannelResponse, + LnurlPayResponsePayerDataOption, LnurlPayResponse, LnurlSuccessResponse, LnurlWithdrawResponse, @@ -19,16 +20,20 @@ def test_response(self): res = LnurlErrorResponse(reason="blah blah blah") assert res.ok is False assert res.error_msg == "blah blah blah" - assert res.json() == '{"status": "ERROR", "reason": "blah blah blah"}' - assert res.dict() == {"status": "ERROR", "reason": "blah blah blah"} + assert res.json() == res.model_dump_json() + assert res.dict() == res.model_dump(mode="json") + assert res.model_dump_json() == '{"status":"ERROR","reason":"blah blah blah"}' + assert res.model_dump(mode="json") == {"status": "ERROR", "reason": "blah blah blah"} class TestLnurlSuccessResponse: def test_success_response(self): res = LnurlSuccessResponse() assert res.ok - assert res.json() == '{"status": "OK"}' - assert res.dict() == {"status": "OK"} + assert res.json() == res.model_dump_json() + assert res.dict() == res.model_dump(mode="json") + assert res.model_dump_json() == '{"status":"OK"}' + assert res.model_dump(mode="json") == {"status": "OK"} class TestLnurlChannelResponse: @@ -38,7 +43,7 @@ class TestLnurlChannelResponse: def test_channel_response(self, d): res = LnurlChannelResponse(**d) assert res.ok - assert res.dict() == {**{"tag": "channelRequest"}, **d} + assert res.model_dump(mode="json") == {**{"tag": "channelRequest"}, **d} @pytest.mark.parametrize( "d", @@ -58,7 +63,7 @@ class TestLnurlHostedChannelResponse: def test_channel_response(self, d): res = LnurlHostedChannelResponse(**d) assert res.ok - assert res.dict() == {**{"tag": "hostedChannelRequest"}, **d} + assert res.model_dump(mode="json") == {**{"tag": "hostedChannelRequest"}, **d} @pytest.mark.parametrize( "d", [{"uri": "invalid", "k1": "c3RyaW5n"}, {"uri": "node_key@ip_address:port_number", "k1": None}] @@ -84,8 +89,8 @@ class TestLnurlPayResponse: ], ) def test_success_response(self, callback: str, min_sendable: int, max_sendable: int, metadata: str): - callback_url = parse_obj_as(CallbackUrl, callback) - data = parse_obj_as(LnurlPayMetadata, metadata) + callback_url = TypeAdapter(CallbackUrl).validate_python(callback) + data = TypeAdapter(LnurlPayMetadata).validate_python(metadata) res = LnurlPayResponse( callback=callback_url, minSendable=MilliSatoshi(min_sendable), @@ -93,21 +98,19 @@ def test_success_response(self, callback: str, min_sendable: int, max_sendable: metadata=data, ) assert res.ok + assert res.json() == res.model_dump_json() + assert res.dict() == res.model_dump(mode="json") assert ( - res.json() == res.json() == '{"tag": "payRequest", "callback": "https://service.io/pay", ' - f'"minSendable": 1000, "maxSendable": 2000, "metadata": {json.dumps(metadata)}}}' - ) - assert ( - res.dict() - == res.dict() - == { - "tag": "payRequest", - "callback": "https://service.io/pay", - "minSendable": 1000, - "maxSendable": 2000, - "metadata": metadata, - } + res.model_dump_json() == '{"tag":"payRequest","callback":"https://service.io/pay",' + f'"minSendable":1000,"maxSendable":2000,"metadata":{json.dumps(metadata)}}}' ) + assert res.model_dump(mode="json") == { + "tag": "payRequest", + "callback": "https://service.io/pay", + "minSendable": 1000, + "maxSendable": 2000, + "metadata": metadata, + } @pytest.mark.parametrize( "d", @@ -141,30 +144,27 @@ def test_success_response( self, callback: str, min_sendable: int, max_sendable: int, metadata: str, comment_allowed: int ): res = LnurlPayResponse( - callback=parse_obj_as(CallbackUrl, callback), + callback=TypeAdapter(CallbackUrl).validate_python(callback), minSendable=MilliSatoshi(min_sendable), maxSendable=MilliSatoshi(max_sendable), - metadata=parse_obj_as(LnurlPayMetadata, metadata), + metadata=TypeAdapter(LnurlPayMetadata).validate_python(metadata), commentAllowed=comment_allowed, ) assert res.ok + assert res.json() == res.model_dump_json() + assert res.dict() == res.model_dump(mode="json") assert ( - res.json() == res.json() == '{"tag": "payRequest", "callback": "https://service.io/pay", ' - f'"minSendable": 1000, "maxSendable": 2000, "metadata": {json.dumps(metadata)}, ' - '"commentAllowed": 555}' - ) - assert ( - res.dict() - == res.dict() - == { - "tag": "payRequest", - "callback": "https://service.io/pay", - "minSendable": 1000, - "maxSendable": 2000, - "metadata": metadata, - "commentAllowed": 555, - } + res.model_dump_json() == '{"tag":"payRequest","callback":"https://service.io/pay",' + f'"minSendable":1000,"maxSendable":2000,"metadata":{json.dumps(metadata)},"commentAllowed":555}}' ) + assert res.model_dump(mode="json") == { + "tag": "payRequest", + "callback": "https://service.io/pay", + "minSendable": 1000, + "maxSendable": 2000, + "metadata": metadata, + "commentAllowed": 555, + } @pytest.mark.parametrize( "d", @@ -202,30 +202,27 @@ class TestLnurlWithdrawResponse: ) def test_success_response(self, callback: str, k1: str, min_withdrawable: int, max_withdrawable: int): res = LnurlWithdrawResponse( - callback=parse_obj_as(CallbackUrl, callback), + callback=TypeAdapter(CallbackUrl).validate_python(callback), k1=k1, minWithdrawable=MilliSatoshi(min_withdrawable), maxWithdrawable=MilliSatoshi(max_withdrawable), ) assert res.ok + assert res.json() == res.model_dump_json() + assert res.dict() == res.model_dump(mode="json") assert ( - res.json() - == res.json() - == '{"tag": "withdrawRequest", "callback": "https://service.io/withdraw", "k1": "c3RyaW5n", ' - '"minWithdrawable": 100, "maxWithdrawable": 200, "defaultDescription": ""}' - ) - assert ( - res.dict() - == res.dict() - == { - "tag": "withdrawRequest", - "callback": "https://service.io/withdraw", - "k1": "c3RyaW5n", - "minWithdrawable": 100, - "maxWithdrawable": 200, - "defaultDescription": "", - } + res.model_dump_json() + == '{"tag":"withdrawRequest","callback":"https://service.io/withdraw","k1":"c3RyaW5n",' + '"minWithdrawable":100,"maxWithdrawable":200,"defaultDescription":""}' ) + assert res.model_dump(mode="json") == { + "tag": "withdrawRequest", + "callback": "https://service.io/withdraw", + "k1": "c3RyaW5n", + "minWithdrawable": 100, + "maxWithdrawable": 200, + "defaultDescription": "", + } @pytest.mark.parametrize( "d", @@ -256,7 +253,7 @@ def test_invalid_data(self, d): def test_invalid_pay_link(self, payLink: str): with pytest.raises(ValidationError): _ = LnurlWithdrawResponse( - callback=parse_obj_as(CallbackUrl, "https://service.io/withdraw/cb"), + callback=TypeAdapter(CallbackUrl).validate_python("https://service.io/withdraw/cb"), k1="c3RyaW5n", minWithdrawable=MilliSatoshi(100), maxWithdrawable=MilliSatoshi(200), @@ -264,13 +261,20 @@ def test_invalid_pay_link(self, payLink: str): ) def test_valid_pay_link(self): - payLink = parse_obj_as(Lnurl, "lnurlp://service.io/pay") + payLink = TypeAdapter(Lnurl).validate_python("lnurlp://service.io/pay") assert payLink.is_lud17 assert payLink.lud17_prefix == "lnurlp" _ = LnurlWithdrawResponse( - callback=parse_obj_as(CallbackUrl, "https://service.io/withdraw/cb"), + callback=TypeAdapter(CallbackUrl).validate_python("https://service.io/withdraw/cb"), k1="c3RyaW5n", minWithdrawable=MilliSatoshi(100), maxWithdrawable=MilliSatoshi(200), payLink=payLink.lud17, ) + + +class TestLnurlBaseModelCompatibility: + def test_nested_models_support_dict_and_json(self): + option = LnurlPayResponsePayerDataOption(mandatory=True) + assert option.dict() == option.model_dump() + assert option.json() == option.model_dump_json() diff --git a/tests/test_types.py b/tests/test_types.py index 46a955d..d65b760 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError, parse_obj_as +from pydantic import HttpUrl, TypeAdapter, ValidationError from lnurl import ( CallbackUrl, @@ -22,20 +22,24 @@ class TestUrl: # https://github.com/pydantic/pydantic/discussions/2450 @pytest.mark.parametrize( - "hostport", - ["service.io:443", "service.io:9000"], + "hostport, expected", + [ + ("service.io:443", "https://service.io/?q=3fc3645b439ce8e7&test=ok"), + ("service.io:9000", "https://service.io:9000/?q=3fc3645b439ce8e7&test=ok"), + ], ) - def test_parameters(self, hostport): - url = parse_obj_as(CallbackUrl, f"https://{hostport}/?q=3fc3645b439ce8e7&test=ok") + def test_parameters(self, hostport, expected): + url = TypeAdapter(CallbackUrl).validate_python(f"https://{hostport}/?q=3fc3645b439ce8e7&test=ok") + assert isinstance(url, HttpUrl) assert url.host == "service.io" - assert url == f"https://{hostport}/?q=3fc3645b439ce8e7&test=ok" - assert url.query_params == {"q": "3fc3645b439ce8e7", "test": "ok"} + assert str(url) == expected + assert dict(url.query_params()) == {"q": "3fc3645b439ce8e7", "test": "ok"} @pytest.mark.parametrize( "url", [ "https://service.io/?q=3fc3645b439ce8e7&test=ok", - "https://[2001:db8:0:1]:80", + "https://[2001:db8::1]:80", "https://protonirockerxow.onion/", "http://protonirockerxow.onion/", "https://📙.la/⚡", # https://emojipedia.org/high-voltage-sign/ @@ -46,8 +50,8 @@ def test_parameters(self, hostport): ], ) def test_valid_callback(self, url): - url = parse_obj_as(CallbackUrl, url) - assert isinstance(url, CallbackUrl) + url = TypeAdapter(CallbackUrl).validate_python(url) + assert isinstance(url, HttpUrl) @pytest.mark.parametrize( "url", @@ -63,7 +67,7 @@ def test_valid_callback(self, url): ) def test_invalid_data_callback(self, url): with pytest.raises(ValidationError): - parse_obj_as(CallbackUrl, url) + TypeAdapter(CallbackUrl).validate_python(url) @pytest.mark.parametrize( "url", @@ -75,7 +79,7 @@ def test_invalid_data_callback(self, url): def test_strict_rfc3986(self, monkeypatch, url): monkeypatch.setenv("LNURL_STRICT_RFC3986", "1") with pytest.raises(ValidationError): - parse_obj_as(CallbackUrl, url) + TypeAdapter(CallbackUrl).validate_python(url) class TestLightningInvoice: @@ -97,7 +101,7 @@ class TestLightningInvoice: ) def test_valid_invoice(self, bech32, hrp, prefix, amount, h): invoice = LightningInvoice(bech32) - assert invoice == parse_obj_as(LightningInvoice, bech32) + assert invoice == TypeAdapter(LightningInvoice).validate_python(bech32) assert invoice.hrp == hrp # TODO: implement these properties # assert invoice.prefix == prefix @@ -106,15 +110,15 @@ def test_valid_invoice(self, bech32, hrp, prefix, amount, h): class TestLightningNode: def test_valid_node(self): - node = parse_obj_as(LightningNodeUri, "node_key@ip_address:port_number") - assert node.key == "node_key" - assert node.ip == "ip_address" - assert node.port == "port_number" + node = TypeAdapter(LightningNodeUri).validate_python("node_key@0.0.0.0:5000") + assert node.username == "node_key" + assert node.host == "0.0.0.0" + assert node.port == "5000" @pytest.mark.parametrize("uri", ["https://service.io/node", "node_key@ip_address", "ip_address:port_number"]) def test_invalid_node(self, uri): with pytest.raises(ValidationError): - parse_obj_as(LightningNodeUri, uri) + TypeAdapter(LightningNodeUri).validate_python(uri) class TestLnurl: @@ -129,24 +133,24 @@ class TestLnurl: "lightning:LNURL1DP68GURN8GHJ7UM9WFMXJCM99E5K7TELWY7NXENRXVMRGDTZXSENJCM98PJNWE3JX56NXCFK89JN2V3K" "XUCRSVTY8YMXGCMYXV6RQD3EXDSKVCTZV5CRGCN9XA3RQCMRVSCNWWRYVCYAE0UU" ), - "https://service.io?a=1&b=2", - "lnurlp://service.io?a=1&b=2", + "https://service.io/?a=1&b=2", + "lnurlp://service.io/?a=1&b=2", "lnurlp://service.io/lnurlp?a=1&b=2", ], ) def test_valid_lnurl_and_bech32(self, lightning): lnurl = Lnurl(lightning) - assert lnurl == parse_obj_as(Lnurl, lightning) + assert lnurl == TypeAdapter(Lnurl).validate_python(lightning) if lnurl.is_lud17: assert lnurl.lud17 == lightning assert lnurl.lud17_prefix == lightning.split("://")[0] assert lnurl.is_lud17 is True - assert lnurl.url == lightning.replace("lnurlp://", "https://") - assert lnurl == lightning.replace("lnurlp://", "https://") + assert str(lnurl.url) == lightning.replace("lnurlp://", "https://") + assert str(lnurl) == lightning else: assert lnurl.bech32 is not None assert lnurl.bech32.hrp == "lnurl" - assert lnurl.bech32 == lnurl or lnurl.url == lnurl + assert lnurl.bech32 == lnurl or str(lnurl.url) == lnurl assert lnurl.lud17 is None assert lnurl.lud17_prefix is None assert lnurl.is_lud17 is False @@ -156,14 +160,14 @@ def test_valid_lnurl_and_bech32(self, lightning): @pytest.mark.parametrize( "url", [ - "lnurlp://service.io?a=1&b=2", - "lnurlc://service.io", - "lnurlw://service.io", - "keyauth://service.io", + "lnurlp://service.io/?a=1&b=2", + "lnurlc://service.io/", + "lnurlw://service.io/", + "keyauth://service.io/", ], ) def test_valid_lnurl_lud17(self, url: str): - _lnurl = parse_obj_as(Lnurl, url) + _lnurl = TypeAdapter(Lnurl).validate_python(url) _prefix = url.split("://")[0] assert _lnurl.lud17 == url @@ -173,8 +177,8 @@ def test_valid_lnurl_lud17(self, url: str): _url = _url.replace("lnurlc://", "https://") _url = _url.replace("lnurlw://", "https://") _url = _url.replace("keyauth://", "https://") + assert str(_lnurl) == url assert str(_lnurl.url) == _url - assert str(_lnurl) == _url @pytest.mark.parametrize( "url", @@ -185,7 +189,8 @@ def test_valid_lnurl_lud17(self, url: str): ) def test_invalid_lnurl(self, url: str): with pytest.raises(ValidationError): - parse_obj_as(Lnurl, url) + TypeAdapter(Lnurl).validate_python(url) + raise ValidationError @pytest.mark.parametrize( "bech32", @@ -197,7 +202,7 @@ def test_invalid_lnurl(self, url: str): ) def test_decode_nolnurl(self, bech32): with pytest.raises(ValidationError): - parse_obj_as(Lnurl, bech32) + TypeAdapter(Lnurl).validate_python(bech32) @pytest.mark.parametrize( "url", @@ -207,10 +212,9 @@ def test_decode_nolnurl(self, bech32): ], ) def test_insecure_lnurl(self, url: str): - lnurl = parse_obj_as(Lnurl, url) - assert lnurl.url.insecure is True + lnurl = TypeAdapter(Lnurl).validate_python(url) + assert lnurl.url.scheme == "http" assert lnurl.url.host == "localhost" - assert lnurl.url.startswith("http://") class TestLnurlPayMetadata: @@ -224,7 +228,7 @@ class TestLnurlPayMetadata: ], ) def test_valid(self, metadata, image_type): - m = parse_obj_as(LnurlPayMetadata, metadata) + m = TypeAdapter(LnurlPayMetadata).validate_python(metadata) assert m.text == "main text" if m.images: @@ -244,7 +248,7 @@ def test_valid(self, metadata, image_type): ) def test_invalid_data(self, metadata): with pytest.raises(ValidationError): - parse_obj_as(LnurlPayMetadata, metadata) + TypeAdapter(LnurlPayMetadata).validate_python(metadata) @pytest.mark.parametrize( "lnaddress", @@ -254,7 +258,7 @@ def test_invalid_data(self, metadata): ) def test_valid_lnaddress(self, lnaddress): lnaddress = LnAddress(lnaddress) - assert isinstance(lnaddress.url, CallbackUrl) + assert isinstance(lnaddress.url, HttpUrl) assert lnaddress.tag is None @pytest.mark.parametrize( @@ -265,7 +269,7 @@ def test_valid_lnaddress(self, lnaddress): ) def test_valid_lnaddress_with_tag(self, lnaddress): lnaddress = LnAddress(lnaddress) - assert isinstance(lnaddress.url, CallbackUrl) + assert isinstance(lnaddress.url, HttpUrl) assert lnaddress.tag == "lud16tag" @pytest.mark.parametrize( @@ -295,7 +299,7 @@ def test_valid_pay_response_payer_data(self): }, ], } - payer_data = parse_obj_as(LnurlPayResponsePayerData, data) + payer_data = TypeAdapter(LnurlPayResponsePayerData).validate_python(data) assert payer_data.name is not None assert payer_data.name.mandatory is True assert payer_data.pubkey is not None @@ -350,7 +354,7 @@ def test_valid_payer_data(self): "sig": "0" * 64, }, } - payer_data = parse_obj_as(LnurlPayerData, data) + payer_data = TypeAdapter(LnurlPayerData).validate_python(data) assert payer_data.name == "John Doe" assert payer_data.pubkey == "03a3xxxxxxxxxxxx" assert payer_data.auth is not None @@ -384,9 +388,9 @@ def test_valid_pay_response_payer_model(self): class TestLnurlErrorResponse: def test_error_res_details(self): res = LnurlErrorResponse(reason="detail") - _dict = res.dict() + _dict = res.model_dump(mode="json") assert "status" in _dict assert _dict["status"] == "ERROR" assert "reason" in _dict assert _dict["reason"] == "detail" - assert res.json() == '{"status": "ERROR", "reason": "detail"}' + assert res.model_dump_json() == '{"status":"ERROR","reason":"detail"}' diff --git a/uv.lock b/uv.lock index f3dc7f2..424dd51 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.13" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -490,7 +499,7 @@ wheels = [ [[package]] name = "lnurl" -version = "0.10.0" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "bech32" }, @@ -522,7 +531,7 @@ requires-dist = [ { name = "coincurve", specifier = ">=20.0.0" }, { name = "httpx" }, { name = "pycryptodomex", specifier = ">=3.21.0" }, - { name = "pydantic", specifier = ">=1.10.0,<2.0.0" }, + { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, ] [package.metadata.requires-dev] @@ -676,29 +685,93 @@ wheels = [ [[package]] name = "pydantic" -version = "1.10.26" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/da/fd89f987a376c807cd81ea0eff4589aade783bbb702637b4734ef2c743a2/pydantic-1.10.26.tar.gz", hash = "sha256:8c6aa39b494c5af092e690127c283d84f363ac36017106a9e66cb33a22ac412e", size = 357906, upload-time = "2025-12-18T15:47:46.557Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/08/2587a6d4314e7539eec84acd062cb7b037638edb57a0335d20e4c5b8878c/pydantic-1.10.26-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f7ae36fa0ecef8d39884120f212e16c06bb096a38f523421278e2f39c1784546", size = 2444588, upload-time = "2025-12-18T15:46:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/47/e6/10df5f08c105bcbb4adbee7d1108ff4b347702b110fed058f6a03f1c6b73/pydantic-1.10.26-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d95a76cf503f0f72ed7812a91de948440b2bf564269975738a4751e4fadeb572", size = 2255972, upload-time = "2025-12-18T15:46:31.72Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7d/fdb961e7adc2c31f394feba6f560ef2c74c446f0285e2c2eb87d2b7206c7/pydantic-1.10.26-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a943ce8e00ad708ed06a1d9df5b4fd28f5635a003b82a4908ece6f24c0b18464", size = 2857175, upload-time = "2025-12-18T15:46:34Z" }, - { url = "https://files.pythonhosted.org/packages/8f/6c/f21e27dda475d4c562bd01b5874284dd3180f336c1e669413b743ca8b278/pydantic-1.10.26-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:465ad8edb29b15c10b779b16431fe8e77c380098badf6db367b7a1d3e572cf53", size = 2947001, upload-time = "2025-12-18T15:46:35.922Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f6/27ea206232cbb6ec24dc4e4e8888a9a734f96a1eaf13504be4b30ef26aa7/pydantic-1.10.26-cp310-cp310-win_amd64.whl", hash = "sha256:80e6be6272839c8a7641d26ad569ab77772809dd78f91d0068dc0fc97f071945", size = 2066217, upload-time = "2025-12-18T15:46:37.614Z" }, - { url = "https://files.pythonhosted.org/packages/1d/c1/d521e64c8130e1ad9d22c270bed3fabcc0940c9539b076b639c88fd32a8d/pydantic-1.10.26-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:116233e53889bcc536f617e38c1b8337d7fa9c280f0fd7a4045947515a785637", size = 2428347, upload-time = "2025-12-18T15:46:39.41Z" }, - { url = "https://files.pythonhosted.org/packages/2c/08/f4b804a00c16e3ea994cb640a7c25c579b4f1fa674cde6a19fa0dfb0ae4f/pydantic-1.10.26-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3cfdd361addb6eb64ccd26ac356ad6514cee06a61ab26b27e16b5ed53108f77", size = 2212605, upload-time = "2025-12-18T15:46:41.006Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/0df4b9efef29bbc5e39f247fcba99060d15946b4463d82a5589cf7923d71/pydantic-1.10.26-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e4451951a9a93bf9a90576f3e25240b47ee49ab5236adccb8eff6ac943adf0f", size = 2753560, upload-time = "2025-12-18T15:46:43.215Z" }, - { url = "https://files.pythonhosted.org/packages/68/66/6ab6c1d3a116d05d2508fce64f96e35242938fac07544d611e11d0d363a0/pydantic-1.10.26-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9858ed44c6bea5f29ffe95308db9e62060791c877766c67dd5f55d072c8612b5", size = 2859235, upload-time = "2025-12-18T15:46:45.112Z" }, - { url = "https://files.pythonhosted.org/packages/61/4e/f1676bb0fcdf6ed2ce4670d7d1fc1d6c3a06d84497644acfbe02649503f1/pydantic-1.10.26-cp311-cp311-win_amd64.whl", hash = "sha256:ac1089f723e2106ebde434377d31239e00870a7563245072968e5af5cc4d33df", size = 2066646, upload-time = "2025-12-18T15:46:46.816Z" }, - { url = "https://files.pythonhosted.org/packages/02/6c/cd97a5a776c4515e6ee2ae81c2f2c5be51376dda6c31f965d7746ce0019f/pydantic-1.10.26-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:468d5b9cacfcaadc76ed0a4645354ab6f263ec01a63fb6d05630ea1df6ae453f", size = 2433795, upload-time = "2025-12-18T15:46:49.321Z" }, - { url = "https://files.pythonhosted.org/packages/47/12/de20affa30dcef728fcf9cc98e13ff4438c7a630de8d2f90eb38eba0891c/pydantic-1.10.26-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2c1b0b914be31671000ca25cf7ea17fcaaa68cfeadf6924529c5c5aa24b7ab1f", size = 2227387, upload-time = "2025-12-18T15:46:50.877Z" }, - { url = "https://files.pythonhosted.org/packages/7b/1d/9d65dcc5b8c17ba590f1f9f486e9306346831902318b7ee93f63516f4003/pydantic-1.10.26-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15b13b9f8ba8867095769e1156e0d7fbafa1f65b898dd40fd1c02e34430973cb", size = 2629594, upload-time = "2025-12-18T15:46:53.42Z" }, - { url = "https://files.pythonhosted.org/packages/3f/76/acb41409356789e23e1a7ef58f93821410c96409183ce314ddb58d97f23e/pydantic-1.10.26-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad7025ca324ae263d4313998e25078dcaec5f9ed0392c06dedb57e053cc8086b", size = 2745305, upload-time = "2025-12-18T15:46:55.987Z" }, - { url = "https://files.pythonhosted.org/packages/22/72/a98c0c5e527a66057d969fedd61675223c7975ade61acebbca9f1abd6dc0/pydantic-1.10.26-cp312-cp312-win_amd64.whl", hash = "sha256:4482b299874dabb88a6c3759e3d85c6557c407c3b586891f7d808d8a38b66b9c", size = 1937647, upload-time = "2025-12-18T15:46:57.905Z" }, - { url = "https://files.pythonhosted.org/packages/1f/98/556e82f00b98486def0b8af85da95e69d2be7e367cf2431408e108bc3095/pydantic-1.10.26-py3-none-any.whl", hash = "sha256:c43ad70dc3ce7787543d563792426a16fd7895e14be4b194b5665e36459dd917", size = 166975, upload-time = "2025-12-18T15:47:44.927Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] @@ -890,6 +963,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" From fe43c08451a0e9557e6ed7c6e6c9b61e2d01921e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 10 Mar 2026 15:17:12 +0100 Subject: [PATCH 2/9] refactor remove model_dump_json --- lnurl/cli.py | 4 ++-- lnurl/models.py | 10 ++-------- tests/test_models.py | 37 +++++++++++++------------------------ 3 files changed, 17 insertions(+), 34 deletions(-) diff --git a/lnurl/cli.py b/lnurl/cli.py index 43573d7..0d1bdad 100755 --- a/lnurl/cli.py +++ b/lnurl/cli.py @@ -48,7 +48,7 @@ def handle(lnurl): handle a LNURL """ decoded = asyncio.run(handle_lnurl(lnurl)) - click.echo(decoded.model_dump_json()) + click.echo(decoded.json()) @click.command() @@ -61,7 +61,7 @@ def execute(lnurl, msat_or_login): if not msat_or_login: raise ValueError("You must provide either an amount_msat or a login_id.") res = asyncio.run(execute_lnurl(lnurl, msat_or_login)) - click.echo(res.model_dump_json()) + click.echo(res.json()) def main(): diff --git a/lnurl/models.py b/lnurl/models.py index 48a3529..ab17bdf 100644 --- a/lnurl/models.py +++ b/lnurl/models.py @@ -28,9 +28,11 @@ class LnurlBaseModel(BaseModel): def dict(self, *args, **kwargs): kwargs.setdefault("mode", "json") + kwargs.setdefault("exclude_none", True) return self.model_dump(*args, **kwargs) def json(self, *args, **kwargs): + kwargs.setdefault("exclude_none", True) return self.model_dump_json(*args, **kwargs) @@ -73,14 +75,6 @@ class LnurlResponseModel(LnurlBaseModel): def ok(self) -> bool: return True - def model_dump(self, *args, **kwargs): - kwargs.setdefault("exclude_none", True) - return super().model_dump(*args, **kwargs) - - def model_dump_json(self, *args, **kwargs): - kwargs.setdefault("exclude_none", True) - return super().model_dump_json(*args, **kwargs) - class LnurlErrorResponse(LnurlResponseModel): status: LnurlStatus = LnurlStatus.error diff --git a/tests/test_models.py b/tests/test_models.py index 967493f..92d406b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,8 +8,8 @@ LnurlChannelResponse, LnurlErrorResponse, LnurlHostedChannelResponse, - LnurlPayResponsePayerDataOption, LnurlPayResponse, + LnurlPayResponsePayerDataOption, LnurlSuccessResponse, LnurlWithdrawResponse, ) @@ -20,20 +20,16 @@ def test_response(self): res = LnurlErrorResponse(reason="blah blah blah") assert res.ok is False assert res.error_msg == "blah blah blah" - assert res.json() == res.model_dump_json() - assert res.dict() == res.model_dump(mode="json") - assert res.model_dump_json() == '{"status":"ERROR","reason":"blah blah blah"}' - assert res.model_dump(mode="json") == {"status": "ERROR", "reason": "blah blah blah"} + assert res.json() == '{"status":"ERROR","reason":"blah blah blah"}' + assert res.dict() == {"status": "ERROR", "reason": "blah blah blah"} class TestLnurlSuccessResponse: def test_success_response(self): res = LnurlSuccessResponse() assert res.ok - assert res.json() == res.model_dump_json() - assert res.dict() == res.model_dump(mode="json") - assert res.model_dump_json() == '{"status":"OK"}' - assert res.model_dump(mode="json") == {"status": "OK"} + assert res.json() == '{"status":"OK"}' + assert res.dict() == {"status": "OK"} class TestLnurlChannelResponse: @@ -43,7 +39,7 @@ class TestLnurlChannelResponse: def test_channel_response(self, d): res = LnurlChannelResponse(**d) assert res.ok - assert res.model_dump(mode="json") == {**{"tag": "channelRequest"}, **d} + assert res.dict() == {**{"tag": "channelRequest"}, **d} @pytest.mark.parametrize( "d", @@ -63,7 +59,7 @@ class TestLnurlHostedChannelResponse: def test_channel_response(self, d): res = LnurlHostedChannelResponse(**d) assert res.ok - assert res.model_dump(mode="json") == {**{"tag": "hostedChannelRequest"}, **d} + assert res.dict() == {**{"tag": "hostedChannelRequest"}, **d} @pytest.mark.parametrize( "d", [{"uri": "invalid", "k1": "c3RyaW5n"}, {"uri": "node_key@ip_address:port_number", "k1": None}] @@ -98,13 +94,11 @@ def test_success_response(self, callback: str, min_sendable: int, max_sendable: metadata=data, ) assert res.ok - assert res.json() == res.model_dump_json() - assert res.dict() == res.model_dump(mode="json") assert ( - res.model_dump_json() == '{"tag":"payRequest","callback":"https://service.io/pay",' + res.json() == '{"tag":"payRequest","callback":"https://service.io/pay",' f'"minSendable":1000,"maxSendable":2000,"metadata":{json.dumps(metadata)}}}' ) - assert res.model_dump(mode="json") == { + assert res.dict() == { "tag": "payRequest", "callback": "https://service.io/pay", "minSendable": 1000, @@ -151,13 +145,11 @@ def test_success_response( commentAllowed=comment_allowed, ) assert res.ok - assert res.json() == res.model_dump_json() - assert res.dict() == res.model_dump(mode="json") assert ( - res.model_dump_json() == '{"tag":"payRequest","callback":"https://service.io/pay",' + res.json() == '{"tag":"payRequest","callback":"https://service.io/pay",' f'"minSendable":1000,"maxSendable":2000,"metadata":{json.dumps(metadata)},"commentAllowed":555}}' ) - assert res.model_dump(mode="json") == { + assert res.dict() == { "tag": "payRequest", "callback": "https://service.io/pay", "minSendable": 1000, @@ -208,14 +200,11 @@ def test_success_response(self, callback: str, k1: str, min_withdrawable: int, m maxWithdrawable=MilliSatoshi(max_withdrawable), ) assert res.ok - assert res.json() == res.model_dump_json() - assert res.dict() == res.model_dump(mode="json") assert ( - res.model_dump_json() - == '{"tag":"withdrawRequest","callback":"https://service.io/withdraw","k1":"c3RyaW5n",' + res.json() == '{"tag":"withdrawRequest","callback":"https://service.io/withdraw","k1":"c3RyaW5n",' '"minWithdrawable":100,"maxWithdrawable":200,"defaultDescription":""}' ) - assert res.model_dump(mode="json") == { + assert res.dict() == { "tag": "withdrawRequest", "callback": "https://service.io/withdraw", "k1": "c3RyaW5n", From a831d6b46e87a8a06fb5c71ca8a3e0d1625c05fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 10 Mar 2026 15:29:49 +0100 Subject: [PATCH 3/9] unused --- lnurl/types.py | 36 +++++------------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/lnurl/types.py b/lnurl/types.py index 56ee9d9..17561f3 100644 --- a/lnurl/types.py +++ b/lnurl/types.py @@ -47,6 +47,7 @@ def __get_data__(cls, bech32: str) -> tuple[str, list[int]]: @classmethod def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + _ = (source_type, handler) def validate(value: Any): hrp, data = cls.__get_data__(str(value)) @@ -120,37 +121,6 @@ def validate_http_host(url: AnyUrl) -> AnyUrl: AfterValidator(validate_http_host), ] -# class Url(AnyUrl): -# max_length = 2047 # https://stackoverflow.com/questions/417142/ - -# # LUD-17: Protocol schemes and raw (non bech32-encoded) URLs. -# allowed_schemes = {"https", "http", "lnurlc", "lnurlw", "lnurlp", "keyauth"} - -# @property -# def is_lud17(self) -> bool: -# uris = ["lnurlc", "lnurlw", "lnurlp", "keyauth"] -# return any(self.scheme == uri for uri in uris) - -# @property -# def query_params(self) -> dict[str, str]: -# return {k: v[0] for k, v in parse_qs(self.query).items()} - -# @property -# def insecure(self) -> bool: -# if not self.host: -# return True -# return self.scheme == "http" or self.host in INSECURE_HOSTS or self.host.endswith(".onion") - -# @classmethod -# def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: -# return core_schema.chain_schema( -# [ -# core_schema.no_info_plain_validator_function(ctrl_characters_validator), -# core_schema.no_info_plain_validator_function(strict_rfc3986_validator), -# core_schema.no_info_plain_validator_function(validate_lnurl_host), -# ] -# ) - class LightningInvoice(Bech32): """Bech32 Lightning invoice.""" @@ -185,6 +155,7 @@ def __get_parts__(cls, uri: str) -> tuple[str, str, str]: @classmethod def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + _ = (source_type, handler) def validate(value: Any): username, host, port = cls.__get_parts__(str(value)) @@ -241,6 +212,7 @@ def clean(cls, lightning: str) -> Url: @classmethod def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + _ = (source_type, handler) def validate(value: Any): _ = cls.clean(value) @@ -379,6 +351,7 @@ def __validate_metadata__(cls, json_str: str) -> list[tuple[str, str]]: @classmethod def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + _ = (source_type, handler) def validate(value: Any): return cls(value, json_obj=cls.__validate_metadata__(str(value))) @@ -442,6 +415,7 @@ def sat(self) -> int: @classmethod def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + _ = (source_type, handler) return core_schema.no_info_after_validator_function( cls, core_schema.int_schema(), From ee554b5f4ed143f173dc938d957b8b5c8fa3e378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 10 Mar 2026 15:38:11 +0100 Subject: [PATCH 4/9] dead code --- tests/test_types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_types.py b/tests/test_types.py index d65b760..c737fad 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -388,9 +388,9 @@ def test_valid_pay_response_payer_model(self): class TestLnurlErrorResponse: def test_error_res_details(self): res = LnurlErrorResponse(reason="detail") - _dict = res.model_dump(mode="json") + _dict = res.dict() assert "status" in _dict assert _dict["status"] == "ERROR" assert "reason" in _dict assert _dict["reason"] == "detail" - assert res.model_dump_json() == '{"status":"ERROR","reason":"detail"}' + assert res.json() == '{"status":"ERROR","reason":"detail"}' From 458e047a5e34f32475ad8c0cde5c9485caffc1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 10 Mar 2026 15:38:21 +0100 Subject: [PATCH 5/9] docs for query_params --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 12a3ba2..4e004c5 100755 --- a/README.md +++ b/README.md @@ -70,9 +70,13 @@ lnurl.bech32.hrp # "lnurl" lnurl.url # "https://service.io/?q=3fc3645b439ce8e7" lnurl.url.host # "service.io" lnurl.url.query # "q=3fc3645b439ce8e7" +lnurl.url.query_params() # [("q", "3fc3645b439ce8e7")] dict(lnurl.url.query_params()) # {"q": "3fc3645b439ce8e7"} ``` +`query_params()` returns a list of `(key, value)` tuples so query parameters stay lossless and ordered. +Use `dict(...)` when you want convenient mapping-style access and duplicate keys do not matter. + Parsing LNURL responses ----------------------- @@ -94,6 +98,7 @@ try: res.maxSendable # int res.max_sats # int res.callback.host # str + res.callback.query_params() # list[tuple] [("amount", "1000")] dict(res.callback.query_params()) # dict res.metadata # str res.metadata.list() # list From 825e7a24aa47a6992cb634f68bea5d62f5ead8a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 10 Mar 2026 15:47:54 +0100 Subject: [PATCH 6/9] add union to url --- lnurl/core.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lnurl/core.py b/lnurl/core.py index cc23b4f..5e15af5 100644 --- a/lnurl/core.py +++ b/lnurl/core.py @@ -23,7 +23,7 @@ LnurlSuccessResponse, LnurlWithdrawResponse, ) -from .types import CallbackUrl, LnAddress, Lnurl +from .types import CallbackUrl, LnAddress, Lnurl, Url USER_AGENT = "lnbits/lnurl" TIMEOUT = 5 @@ -44,16 +44,17 @@ def encode(url: str) -> Lnurl: async def get( - url: str, + url: str | Url | CallbackUrl, *, response_class: Optional[Any] = None, user_agent: Optional[str] = None, timeout: Optional[int] = None, ) -> LnurlResponseModel: + request_url = str(url) headers = {"User-Agent": user_agent or USER_AGENT} async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: try: - res = await client.get(url, timeout=timeout or TIMEOUT) + res = await client.get(request_url, timeout=timeout or TIMEOUT) res.raise_for_status() except Exception as exc: raise LnurlResponseException(str(exc)) from exc @@ -61,7 +62,7 @@ async def get( try: _json = res.json() except JSONDecodeError as exc: - raise LnurlResponseException(f"Invalid JSON response from {url}") from exc + raise LnurlResponseException(f"Invalid JSON response from {request_url}") from exc if response_class: if not issubclass(response_class, LnurlResponseModel): @@ -80,7 +81,7 @@ async def handle( try: if "@" in lnurl: lnaddress = LnAddress(lnurl) - return await get(str(lnaddress.url), response_class=response_class, user_agent=user_agent, timeout=timeout) + return await get(lnaddress.url, response_class=response_class, user_agent=user_agent, timeout=timeout) lnurl = Lnurl(lnurl) except (ValidationError, ValueError): raise InvalidLnurl @@ -96,7 +97,7 @@ async def handle( raise LnurlResponseException("k1 parameter not found in LNURLauth URL") return LnurlAuthResponse(callback=callback_url, k1=k1) - return await get(str(lnurl.url), response_class=response_class, user_agent=user_agent, timeout=timeout) + return await get(lnurl.url, response_class=response_class, user_agent=user_agent, timeout=timeout) async def execute( From d40a61ddc17b4ca463e64a17381c2e9d446a10f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 10 Mar 2026 15:49:52 +0100 Subject: [PATCH 7/9] fixup! --- lnurl/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lnurl/core.py b/lnurl/core.py index 5e15af5..8bae33c 100644 --- a/lnurl/core.py +++ b/lnurl/core.py @@ -142,7 +142,7 @@ async def execute_pay_request( headers = {"User-Agent": user_agent or USER_AGENT} async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: res2 = await client.get( - url=str(res.callback), + url=res.callback, params=params, timeout=timeout or TIMEOUT, ) @@ -186,7 +186,7 @@ async def execute_login( headers = {"User-Agent": user_agent or USER_AGENT} async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: res2 = await client.get( - url=str(res.callback), + url=res.callback, params={ "key": key, "sig": sig, @@ -217,7 +217,7 @@ async def execute_withdraw( headers = {"User-Agent": user_agent or USER_AGENT} async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: res2 = await client.get( - url=str(res.callback), + url=res.callback, params={ "k1": res.k1, "pr": pr, From a39a5cd2ead2fa0cf27d7b71cf8695842408cca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 10 Mar 2026 15:58:59 +0100 Subject: [PATCH 8/9] check enums --- lnurl/models.py | 18 ++++++------------ tests/test_models_from_dict.py | 2 +- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/lnurl/models.py b/lnurl/models.py index ab17bdf..91eda84 100644 --- a/lnurl/models.py +++ b/lnurl/models.py @@ -26,6 +26,11 @@ class LnurlBaseModel(BaseModel): + model_config = ConfigDict( + use_enum_values=True, + extra="forbid", + ) + def dict(self, *args, **kwargs): kwargs.setdefault("mode", "json") kwargs.setdefault("exclude_none", True) @@ -65,12 +70,6 @@ class AesAction(LnurlPaySuccessAction): class LnurlResponseModel(LnurlBaseModel): - - model_config = ConfigDict( - use_enum_values=True, - extra="forbid", - ) - @property def ok(self) -> bool: return True @@ -80,11 +79,6 @@ class LnurlErrorResponse(LnurlResponseModel): status: LnurlStatus = LnurlStatus.error reason: str - model_config = ConfigDict( - use_enum_values=True, - extra="forbid", - ) - @property def error_msg(self) -> str: return self.reason @@ -235,7 +229,7 @@ def max_less_than_min(self): # LUD-08: Fast withdrawRequest. @property def fast_withdraw_query(self) -> str: - return "&".join([f"{k}={v}" for k, v in self.model_dump().items()]) + return "&".join([f"{k}={v}" for k, v in self.dict().items()]) @property def min_sats(self) -> int: diff --git a/tests/test_models_from_dict.py b/tests/test_models_from_dict.py index 0b71904..af086fb 100644 --- a/tests/test_models_from_dict.py +++ b/tests/test_models_from_dict.py @@ -84,7 +84,7 @@ def test_pay_action_aes(self): assert isinstance(res.successAction, LnurlPaySuccessAction) assert res.ok assert res.successAction - assert res.successAction.tag == LnurlPaySuccessActionTag.aes + assert LnurlPaySuccessActionTag.aes.value == res.successAction.tag assert res.successAction.description == "your will receive a secret message" assert len(res.successAction.iv) == 24 assert len(res.successAction.ciphertext) == 44 From 43ef317ac81c1509501263bbe94b28b0bdee374d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 10 Mar 2026 16:05:34 +0100 Subject: [PATCH 9/9] revert --- lnurl/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lnurl/core.py b/lnurl/core.py index 8bae33c..5e15af5 100644 --- a/lnurl/core.py +++ b/lnurl/core.py @@ -142,7 +142,7 @@ async def execute_pay_request( headers = {"User-Agent": user_agent or USER_AGENT} async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: res2 = await client.get( - url=res.callback, + url=str(res.callback), params=params, timeout=timeout or TIMEOUT, ) @@ -186,7 +186,7 @@ async def execute_login( headers = {"User-Agent": user_agent or USER_AGENT} async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: res2 = await client.get( - url=res.callback, + url=str(res.callback), params={ "key": key, "sig": sig, @@ -217,7 +217,7 @@ async def execute_withdraw( headers = {"User-Agent": user_agent or USER_AGENT} async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: res2 = await client.get( - url=res.callback, + url=str(res.callback), params={ "k1": res.k1, "pr": pr,