Compare commits

..
Author SHA1 Message Date
ArcandGitHub 5a6ff7ed02 Merge branch 'dev' into trans_exts 2026-05-22 14:15:17 +01:00
6664eebf5a feat: add visibility toggle to inputs (#3940)
Co-authored-by: Arc <ben@arc.wales>
2026-05-22 14:14:19 +01:00
2a2af81827 feat: hide burger bg toggle (#3969)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-05-22 14:04:46 +01:00
ArcandGitHub 9b47f6323f fIx: pyinstrument import needed for nix (#3985) 2026-05-22 13:56:06 +03:00
Vlad StanandGitHub a61807a257 feat: square integration (#3962) 2026-05-22 12:37:51 +03:00
Arc 335c056a4b feat: translation support for extensions 2026-05-01 13:05:47 +01:00
27 changed files with 196 additions and 140 deletions
+2
View File
@@ -119,6 +119,8 @@ LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# Toggle the background styling on burger menus / drawers
# LNBITS_DEFAULT_BURGER_MENU_BACKGROUND=true
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
######################################
-4
View File
@@ -133,10 +133,6 @@ async def create_fiat_invoice(
raise ValueError(
f"Fiat provider '{fiat_provider_name}' is not enabled.",
)
if settings.fiat_providers_admin_only:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not settings.is_admin_user(wallet.user):
raise ValueError("Fiat providers are available to admins only.")
if invoice_data.unit == "sat":
raise ValueError("Fiat provider cannot be used with satoshis.")
+7 -3
View File
@@ -20,7 +20,7 @@ from lnbits.decorators import (
check_first_install,
check_user_exists,
)
from lnbits.helpers import check_callback_url, template_renderer
from lnbits.helpers import check_callback_url, extension_id_from_path, template_renderer
from lnbits.settings import settings
from ..crud import get_user
@@ -198,7 +198,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
async def index(
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
return template_renderer().TemplateResponse(
return template_renderer(
extension_id=extension_id_from_path(request.url.path)
).TemplateResponse(
request,
"base.html",
{
@@ -211,7 +213,9 @@ async def index(
@generic_router.get("/node/public")
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
async def index_public(request: Request) -> HTMLResponse:
return template_renderer().TemplateResponse(request, "base.html", {"public": True})
return template_renderer(
extension_id=extension_id_from_path(request.url.path)
).TemplateResponse(request, "base.html", {"public": True})
@generic_router.get("/uuidv4/{hex_value}")
+47 -1
View File
@@ -52,7 +52,45 @@ def static_url_for(static: str, path: str) -> str:
return f"/{static}/{path}?v={settings.server_startup_time}"
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
def extension_id_from_path(path: str) -> str | None:
parts = [part for part in path.split("/") if part]
if not parts:
return None
if len(parts) >= 3 and parts[0] == "upgrades":
return parts[2]
ext_id = parts[0]
ext_i18n_dir = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "static", "i18n"
)
if ext_i18n_dir.is_dir():
return ext_id
return None
def extension_i18n_urls(extension_id: str | None) -> list[str]:
if not extension_id:
return []
i18n_dir = Path(
settings.lnbits_extensions_path, "extensions", extension_id, "static", "i18n"
)
if not i18n_dir.is_dir():
return []
files = [file.name for file in i18n_dir.glob("*.js") if file.is_file()]
return [
static_url_for(f"{extension_id}/static", f"i18n/{filename}")
for filename in sorted(files, key=lambda name: (name != "en.js", name))
]
def template_renderer(
additional_folders: list | None = None,
extension_id: str | None = None,
) -> Jinja2Templates:
folders = [
"lnbits/templates",
settings.extension_builder_working_dir_path.as_posix(),
@@ -86,6 +124,14 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
if not extension_id and additional_folders:
for folder in additional_folders:
parts = Path(folder).parts
if parts and parts[-1] == "templates" and len(parts) >= 2:
extension_id = parts[-2]
break
t.env.globals["INCLUDED_EXTENSION_I18N"] = extension_i18n_urls(extension_id)
# backwards compatibility for extensions (tpos)
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
+3 -11
View File
@@ -300,6 +300,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_default_card_rounded: bool = Field(default=True)
lnbits_default_card_gradient: bool = Field(default=True)
lnbits_default_card_shadow: bool = Field(default=False)
lnbits_default_burger_menu_background: bool = Field(default=True)
class OpsSettings(LNbitsSettings):
@@ -770,8 +771,6 @@ class FiatProvidersSettings(
SquareFiatProvider,
RevolutFiatProvider,
):
fiat_providers_admin_only: bool = Field(default=True)
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
"""
Checks if a specific fiat provider is enabled.
@@ -792,15 +791,6 @@ class FiatProvidersSettings(
"""
Returns a list of fiat payment methods allowed for the user.
"""
if self.fiat_providers_admin_only:
if not self.is_admin_user(user_id):
return []
return [
provider
for provider in ["stripe", "paypal", "square", "revolut"]
if self.is_fiat_provider_enabled(provider)
]
allowed_providers = []
if self.stripe_enabled and (
not self.stripe_limits.allowed_users
@@ -1292,6 +1282,7 @@ class PublicSettings(BaseModel):
default_card_rounded: bool = Field(alias="defaultCardRounded")
default_card_gradient: bool = Field(alias="defaultCardGradient")
default_card_shadow: bool = Field(alias="defaultCardShadow")
default_burger_menu_background: bool = Field(alias="defaultBurgerMenuBackground")
denomination: str | None = Field()
extensions: list[str] = Field()
allowed_currencies: list[str] = Field(alias="allowedCurrencies")
@@ -1355,6 +1346,7 @@ class PublicSettings(BaseModel):
defaultCardRounded=settings.lnbits_default_card_rounded,
defaultCardGradient=settings.lnbits_default_card_gradient,
defaultCardShadow=settings.lnbits_default_card_shadow,
defaultBurgerMenuBackground=settings.lnbits_default_burger_menu_background,
denomination=settings.lnbits_denomination,
extensions=list(settings.lnbits_installed_extensions_ids),
allowedCurrencies=settings.lnbits_allowed_currencies,
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -395,6 +395,13 @@ body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card,
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
body.no-burger-background .q-drawer {
background-color: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
}
:root {
--size: 100px;
--gap: 25px;
+2
View File
@@ -490,6 +490,8 @@ window.localisation.en = {
toggle_card_gradient: 'Toggle gradient on cards',
card_shadow: 'Card Shadow',
toggle_card_shadow: 'Toggle shadow on cards',
burger_menu_background: 'Burger Menu Background',
toggle_burger_menu_background: 'Toggle burger menu background',
language: 'Language',
assets: 'Assets',
max_asset_size_mb: 'Max Asset Size (MB)',
+3 -1
View File
@@ -452,7 +452,9 @@ window.app.component('username-password', {
confirmationMethod: 'code',
confirmationEmail: '',
confirmationCode: this.invitationCode || '',
showConfirmationCode: false
showConfirmationCode: false,
showPwd: false,
showPwdRepeat: false
}
},
methods: {
@@ -12,21 +12,6 @@ window.app.component('lnbits-admin-fiat-providers', {
}
},
computed: {
fiatProvidersAllUsers: {
get() {
return this.formData?.fiat_providers_admin_only === false
},
set(value) {
this.formData.fiat_providers_admin_only = !value
this.formData.touch = null
}
},
fiatProviderAccessLabel() {
return this.fiatProvidersAllUsers ? 'All users' : 'Admins only'
},
secretInputStyle() {
return this.hideInputToggle ? {'-webkit-text-security': 'disc'} : {}
},
stripeWebhookUrl() {
return (
this.formData?.stripe_payment_webhook_url ||
@@ -69,6 +69,14 @@ window.app.component('lnbits-theme', {
document.body.classList.remove('card-shadow')
}
},
'g.burgerMenuChoice'(val) {
this.$q.localStorage.set('lnbits.burgerMenu', val)
if (val === true) {
document.body.classList.remove('no-burger-background')
} else {
document.body.classList.add('no-burger-background')
}
},
'g.mobileSimple'(val) {
this.$q.localStorage.set('lnbits.mobileSimple', val)
if (val === true) {
@@ -150,6 +158,9 @@ window.app.component('lnbits-theme', {
if (this.g.cardShadowChoice === true) {
document.body.classList.add('card-shadow')
}
if (this.g.burgerMenuChoice !== true) {
document.body.classList.add('no-burger-background')
}
if (this.g.bgimageChoice !== '') {
document.body.classList.add('bg-image')
document.body.style.setProperty(
+4
View File
@@ -29,6 +29,10 @@ window.g = Vue.reactive({
SETTINGS.defaultCardGradient
),
cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow),
burgerMenuChoice: localStore(
'lnbits.burgerMenu',
SETTINGS.defaultBurgerMenuBackground
),
reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction),
bgimageChoice: localStore(
'lnbits.backgroundImage',
+2 -1
View File
@@ -732,7 +732,8 @@ window.PageAccount = {
darkChoice: this.g.settings.defaultDark,
cardRoundedChoice: this.g.settings.defaultCardRounded,
cardGradientChoice: this.g.settings.defaultCardGradient,
cardShadowChoice: this.g.settings.defaultCardShadow
cardShadowChoice: this.g.settings.defaultCardShadow,
burgerMenuChoice: this.g.settings.defaultBurgerMenuBackground
}
this.siteCustomisationChanged(defaults)
}
+9
View File
@@ -70,3 +70,12 @@ body.card-shadow.body--dark {
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
}
body.no-burger-background {
.q-drawer {
background-color: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
}
}
+3 -1
View File
@@ -90,7 +90,9 @@
window.g.isPublicPage = false
{% endif %}
</script>
{% endif %}
{% endif %} {% for url in INCLUDED_EXTENSION_I18N %}
<script src="{{ url }}"></script>
{% endfor %}
<!-- app init -->
<script>
window.app = Vue.createApp({
+35 -5
View File
@@ -774,7 +774,13 @@ include('components/lnbits-error.vue') %}
v-model="password"
name="password"
:label="$t('password') + ' *'"
type="password"
:type="showPwd ? 'text' : 'password'"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
></q-input>
<div class="row justify-end">
<q-btn
@@ -803,16 +809,28 @@ include('components/lnbits-error.vue') %}
filled
v-model="password"
:label="$t('password') + ' *'"
type="password"
:type="showPwd ? 'text' : 'password'"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
></q-input>
<q-input
dense
filled
v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'"
type="password"
:type="showPwdRepeat ? 'text' : 'password'"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwdRepeat ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwdRepeat = !showPwdRepeat"
/> </template
></q-input>
<div
v-if="confirmationMethodsCount > 1"
@@ -925,16 +943,28 @@ include('components/lnbits-error.vue') %}
filled
v-model="password"
:label="$t('password') + ' *'"
type="password"
:type="showPwd ? 'text' : 'password'"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
></q-input>
<q-input
dense
filled
v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'"
type="password"
:type="showPwdRepeat ? 'text' : 'password'"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwdRepeat ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwdRepeat = !showPwdRepeat"
/> </template
></q-input>
<div class="row justify-end">
<q-btn
@@ -1,5 +1,5 @@
<template id="lnbits-admin-fiat-providers">
<h6 class="q-my-none q-mb-sm row items-center q-gutter-sm">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('fiat_providers')"></span>
<q-btn
round
@@ -7,18 +7,6 @@
@click="hideInputToggle = !hideInputToggle"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
<q-toggle
dense
size="sm"
color="warning"
v-model="fiatProvidersAllUsers"
:label="fiatProviderAccessLabel"
>
<q-tooltip>
If enabled for all users, your users may pass a memo that suspends your
account with your fiat providers
</q-tooltip>
</q-toggle>
</h6>
<div class="row">
<div class="col">
@@ -58,11 +46,7 @@
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_api_secret_key"
:label="$t('secret_key')"
></q-input>
@@ -124,11 +108,7 @@
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_webhook_signing_secret"
:label="$t('signing_secret')"
:hint="$t('signing_secret_hint')"
@@ -345,22 +325,14 @@
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.paypal_client_id"
:label="$t('client_id')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.paypal_client_secret"
:label="$t('secret_key')"
></q-input>
@@ -422,11 +394,7 @@
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.paypal_webhook_id"
:label="$t('webhook_id')"
:hint="$t('webhook_id_hint')"
@@ -643,11 +611,7 @@
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.square_access_token"
:label="$t('access_token')"
></q-input>
@@ -724,11 +688,7 @@
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.square_webhook_signature_key"
:label="$t('signing_secret')"
:hint="$t('square_webhook_signature_key_hint')"
@@ -940,11 +900,7 @@
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.revolut_api_secret_key"
label="API secret key"
></q-input>
@@ -320,6 +320,15 @@
>
</q-toggle>
</div>
<div class="col-12 col-sm-6 col-lg-2">
<q-toggle
type="bool"
v-model="formData.lnbits_default_burger_menu_background"
color="primary"
:label="$t('burger_menu_background')"
>
</q-toggle>
</div>
</div>
</div>
</q-card-section>
+24
View File
@@ -601,6 +601,30 @@
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('burger_menu_background')"></span>
</div>
<div class="col-8">
<q-toggle
dense
flat
round
icon="menu_open"
v-model="g.burgerMenuChoice"
@update:model-value="
siteCustomisationChanged({burgerMenuChoice: $event})
"
>
<q-tooltip
><span
v-text="$t('toggle_burger_menu_background')"
></span
></q-tooltip>
</q-toggle>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('toggle_darkmode')"></span>
Generated
+3 -3
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.4.0 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -3394,7 +3394,7 @@ version = "5.1.2"
description = "Call stack profiler for Python. Shows you why your code is slow!"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
groups = ["main"]
files = [
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f224fe80ba288a00980af298d3808219f9d246fd95b4f91729c9c33a0dc54fe6"},
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7df09fc0d5b72daf48b73cdf07738761bff7f656c81aff686b3ccdd7d2abe236"},
@@ -5110,4 +5110,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "4dd6ccfc459064fb5335531d4ceb9c0f228279b1c95d0911e895232a51b90006"
content-hash = "4050934800e6dfcc5242d1847d3db69eb6e91d634c09368a28255ad3c908b568"
+1 -1
View File
@@ -52,6 +52,7 @@ dependencies = [
"python-dotenv~=1.2.1",
"greenlet~=3.3.0",
"urllib3>=2.7.0",
"pyinstrument>=5.1.2",
]
[project.scripts]
@@ -84,7 +85,6 @@ dev = [
"types-mock~=5.2.0.20250924",
"mock~=5.2.0",
"grpcio-tools~=1.76.0",
"pyinstrument>=5.1.2",
]
[tool.uv]
-36
View File
@@ -76,7 +76,6 @@ class MockHTTPClient:
def fiat_provider_test_settings(settings: Settings):
original_lnbits_running = settings.lnbits_running
original_allowed_currencies = settings.lnbits_allowed_currencies
original_fiat_providers_admin_only = settings.fiat_providers_admin_only
original_paypal_enabled = settings.paypal_enabled
original_square_enabled = settings.square_enabled
original_square_api_endpoint = settings.square_api_endpoint
@@ -96,14 +95,12 @@ def fiat_provider_test_settings(settings: Settings):
original_revolut_webhook_signing_secret = settings.revolut_webhook_signing_secret
original_revolut_limits = settings.revolut_limits.copy(deep=True)
settings.lnbits_allowed_currencies = []
settings.fiat_providers_admin_only = False
settings.paypal_enabled = False
settings.square_enabled = False
settings.revolut_enabled = False
yield
settings.lnbits_running = original_lnbits_running
settings.lnbits_allowed_currencies = original_allowed_currencies
settings.fiat_providers_admin_only = original_fiat_providers_admin_only
settings.paypal_enabled = original_paypal_enabled
settings.square_enabled = original_square_enabled
settings.square_api_endpoint = original_square_api_endpoint
@@ -220,39 +217,6 @@ async def test_create_wallet_fiat_invoice_allowed_users(
assert user.fiat_providers == ["revolut"]
@pytest.mark.anyio
async def test_fiat_providers_admin_only_default(to_user: User, settings: Settings):
original_admin_users = list(settings.lnbits_admin_users)
try:
settings.fiat_providers_admin_only = True
settings.stripe_enabled = True
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == []
settings.lnbits_admin_users.append(to_user.id)
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == ["stripe"]
finally:
settings.lnbits_admin_users = original_admin_users
@pytest.mark.anyio
async def test_create_wallet_fiat_invoice_admin_only_rejects_non_admin(
to_wallet: Wallet, settings: Settings
):
settings.fiat_providers_admin_only = True
settings.stripe_enabled = True
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider="stripe"
)
with pytest.raises(ValueError, match="available to admins only"):
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
@pytest.mark.anyio
async def test_create_wallet_fiat_invoice_fiat_limits_fail(
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
+2
View File
@@ -37,12 +37,14 @@ def test_dict_to_settings_parses_known_values():
{
"lnbits_site_title": "Test Title",
"lnbits_service_fee": 5,
"lnbits_default_burger_menu_background": False,
"ignored_field": "ignored",
}
)
assert parsed.lnbits_site_title == "Test Title"
assert parsed.lnbits_service_fee == 5
assert parsed.lnbits_default_burger_menu_background is False
assert not hasattr(parsed, "ignored_field")
+8
View File
@@ -232,6 +232,14 @@ def test_installed_extensions_settings_activate_and_deactivate_paths():
assert installed.find_extension_redirect("/.well-known/lnurlp", []) is None
def test_public_settings_include_burger_menu_background(settings: Settings):
settings.lnbits_default_burger_menu_background = False
public_settings = PublicSettings.from_settings(settings)
assert public_settings.default_burger_menu_background is False
def test_installed_extensions_settings_detects_conflicting_redirects():
installed = InstalledExtensionsSettings(
lnbits_extensions_redirects=[
Generated
+2 -2
View File
@@ -1302,6 +1302,7 @@ dependencies = [
{ name = "protobuf" },
{ name = "pycryptodomex" },
{ name = "pydantic" },
{ name = "pyinstrument" },
{ name = "pyjwt" },
{ name = "pyln-client" },
{ name = "pynostr" },
@@ -1347,7 +1348,6 @@ dev = [
{ name = "openai" },
{ name = "openapi-spec-validator" },
{ name = "pre-commit" },
{ name = "pyinstrument" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-httpserver" },
@@ -1388,6 +1388,7 @@ requires-dist = [
{ name = "psycopg2-binary", marker = "extra == 'migration'", specifier = "~=2.9.11" },
{ name = "pycryptodomex", specifier = "~=3.23.0" },
{ name = "pydantic", specifier = "~=1.10.26" },
{ name = "pyinstrument", specifier = ">=5.1.2" },
{ name = "pyjwt", specifier = "~=2.12.0" },
{ name = "pyln-client", specifier = "~=25.12.0" },
{ name = "pynostr", specifier = "~=0.7.0" },
@@ -1423,7 +1424,6 @@ dev = [
{ name = "openai", specifier = "~=2.14.0" },
{ name = "openapi-spec-validator", specifier = "~=0.7.2" },
{ name = "pre-commit", specifier = "~=4.5.1" },
{ name = "pyinstrument", specifier = ">=5.1.2" },
{ name = "pytest", specifier = "~=9.0.2" },
{ name = "pytest-cov", specifier = "~=7.0.0" },
{ name = "pytest-httpserver", specifier = "~=1.1.3" },