Merge pull request 'door section' (#21) from dev into master
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
## Gatekeeper - Door access system
|
||||
#### Status: WIP - getting there o.o
|
||||
#### Status: "WORKING" - Base functionality is there, mayor issues
|
||||
|
||||
Start prod server `nix run`<br>
|
||||
Start dev server `nix run .#dev` or `nix run .#dev -- {args}`<br>
|
||||
Interactive dev with `nix develop`, then sync deps with `uv sync`<br>
|
||||
There is a nix module you can use by importing `inputs.gatekeeper.nixosModules.gatekeeper`<br>
|
||||
Swagger UI @ http://127.0.0.1:8000/api/v1/docs<br>
|
||||
OpenApi @ http://127.0.0.1:8000/api/v1/openapi.json<br>
|
||||
|
||||
@@ -33,17 +34,13 @@ Range: The range of the ACR1552U was much better at over 60mm (almost 70mm if yo
|
||||
|
||||
|
||||
#### Issues:
|
||||
- cards can only unlock, not lock
|
||||
- documentation missing
|
||||
- raspberry pi image not working
|
||||
- no door state
|
||||
- no door operations
|
||||
- hardcoded secret key in auth.py -> centralise env var loading
|
||||
- i don't like the error handling in the scanner - doesn't pass errors correctly
|
||||
- cors for frontend: https://fastapi.tiangolo.com/tutorial/cors
|
||||
- Load cors from env var or something
|
||||
- BackgroundScanner shouldn't get a single session for the whole lifecycle
|
||||
- input validation maybe
|
||||
- too many imports
|
||||
- inconsistent logging (request logging?)
|
||||
- rate limiting maybe
|
||||
- pretty sure the controllers are doing too much stuff
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .controllers import cardManager, userManager
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(userManager.user_router)
|
||||
app.include_router(cardManager.card_router)
|
||||
|
||||
@@ -10,14 +10,21 @@ door_router = APIRouter(prefix="/api/v1/door", tags=["Door"])
|
||||
|
||||
@door_router.put("/open")
|
||||
def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
|
||||
doorService.opendoor()
|
||||
doorService.openDoor()
|
||||
|
||||
|
||||
@door_router.put("/close")
|
||||
def close_door(
|
||||
db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)
|
||||
):
|
||||
doorService.closedoor()
|
||||
doorService.closeDoor()
|
||||
|
||||
|
||||
@door_router.put("/status")
|
||||
def is_door_open(
|
||||
db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)
|
||||
):
|
||||
return doorService.isDoorOpen()
|
||||
|
||||
|
||||
@door_router.post("/test")
|
||||
|
||||
21
app/main.py
21
app/main.py
@@ -1,15 +1,10 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# ruff: disable[E402]
|
||||
from app.controllers import (
|
||||
aaManager,
|
||||
cardManager,
|
||||
@@ -20,10 +15,9 @@ from app.controllers import (
|
||||
)
|
||||
from app.services.auth import create_first_user, token_router
|
||||
from app.services.database import create_db_and_tables, get_db_session
|
||||
from app.services.door import DoorController, init_controller
|
||||
from app.services.scanner import BackgroundScanner
|
||||
from app.services.settings import verify_settings
|
||||
|
||||
# ruff: enable[E402]
|
||||
from app.services.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,11 +28,16 @@ logging.basicConfig(level=logging.INFO)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
verify_settings()
|
||||
create_db_and_tables()
|
||||
create_first_user(db=get_db_session())
|
||||
|
||||
if not os.getenv("DISABLE_CARDS"):
|
||||
init_controller(
|
||||
DoorController(
|
||||
lock_pin=settings.lock_pin,
|
||||
unlock_pin=settings.unlock_pin,
|
||||
mock_factory=settings.mock_gpio,
|
||||
),
|
||||
)
|
||||
if not settings.disable_cards:
|
||||
scanner.start()
|
||||
|
||||
logger.info("-" * 63)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -14,10 +13,11 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.model.models import Token, TokenData, UserDB
|
||||
from app.services.database import add_and_refresh, get_session
|
||||
from app.services.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", default="ff" * 16)
|
||||
SECRET_KEY = settings.secret_key
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 120
|
||||
|
||||
|
||||
@@ -1,38 +1,48 @@
|
||||
import logging
|
||||
from os import getenv, path
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
from app.services.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SQLALCHEMY_DATABASE_PATH = getenv("SQLALCHEMY_DATABASE_PATH", "./gatekeeper.db")
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///" + SQLALCHEMY_DATABASE_PATH
|
||||
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL)
|
||||
@lru_cache
|
||||
def get_engine():
|
||||
return create_engine(
|
||||
settings.sqlalchemy_database_url, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
|
||||
def create_db_and_tables():
|
||||
if not path.exists(SQLALCHEMY_DATABASE_PATH):
|
||||
SQLModel.metadata.create_all(engine)
|
||||
inspector = inspect(get_engine())
|
||||
existing_tables = inspector.get_table_names()
|
||||
if not existing_tables:
|
||||
SQLModel.metadata.create_all(get_engine())
|
||||
from alembic.config import Config
|
||||
|
||||
from alembic import command
|
||||
|
||||
alembic_cfg = Config("./alembic.ini")
|
||||
alembic_cfg.attributes["sqlalchemy.url"] = SQLALCHEMY_DATABASE_URL
|
||||
alembic_cfg = Config(settings.alembic_config)
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", str(get_engine().url))
|
||||
alembic_cfg.attributes["sqlalchemy.url"] = settings.sqlalchemy_database_url
|
||||
command.stamp(alembic_cfg, "head")
|
||||
logger.info("Database created and tables initialized.")
|
||||
else:
|
||||
logger.info("Database already exists")
|
||||
logger.info(
|
||||
"Database already initialized (%d tables found).", len(existing_tables)
|
||||
)
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as db:
|
||||
with Session(get_engine()) as db:
|
||||
yield db
|
||||
|
||||
|
||||
def get_db_session():
|
||||
return Session(engine)
|
||||
return Session(get_engine())
|
||||
|
||||
|
||||
def add_and_refresh(db: Session, obj):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from time import sleep
|
||||
|
||||
import lgpio
|
||||
from sqlalchemy import exc
|
||||
from sqlmodel import select
|
||||
|
||||
@@ -9,25 +11,81 @@ from app.services.database import Session, add_and_refresh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
doorIsOpen = True
|
||||
# I think this could also be gpio controlled
|
||||
|
||||
# See: https://github.com/technyon/nuki_hub#gpio-lock-control-optional
|
||||
# TODO: add sensor pin
|
||||
class DoorController:
|
||||
def __init__(
|
||||
self,
|
||||
lock_pin: int = 17, # connected to 20 on the esp
|
||||
unlock_pin: int = 18, # connected to 21 on the esp
|
||||
mock_factory: bool = False,
|
||||
):
|
||||
self._is_open: bool = False
|
||||
self._lock_pin = lock_pin
|
||||
self._unlock_pin = unlock_pin
|
||||
self._mock = mock_factory
|
||||
self._chip = None
|
||||
|
||||
if not mock_factory:
|
||||
self._chip = lgpio.gpiochip_open(0)
|
||||
lgpio.gpio_claim_output(self._chip, unlock_pin, 1)
|
||||
lgpio.gpio_claim_output(self._chip, lock_pin, 1)
|
||||
|
||||
logger.info(
|
||||
"DoorController started. lock=%s unlock=%s mock=%s",
|
||||
lock_pin,
|
||||
unlock_pin,
|
||||
mock_factory,
|
||||
)
|
||||
|
||||
def open(self):
|
||||
if self._mock:
|
||||
self._is_open = True
|
||||
logger.info("Dor unlocked.[MOCK]")
|
||||
return
|
||||
|
||||
lgpio.gpio_write(self._chip, self._unlock_pin, 0)
|
||||
sleep(0.4)
|
||||
lgpio.gpio_write(self._chip, self._unlock_pin, 1)
|
||||
self._is_open = True
|
||||
logger.info("Door unlocked!")
|
||||
|
||||
def close(self):
|
||||
lgpio.gpio_write(self._chip, self._lock_pin, 0)
|
||||
sleep(0.4)
|
||||
lgpio.gpio_write(self._chip, self._lock_pin, 1)
|
||||
self._is_open = False
|
||||
logger.info("Door locked!")
|
||||
|
||||
def is_open(self):
|
||||
return self._is_open
|
||||
|
||||
|
||||
_contoller: DoorController | None = None
|
||||
|
||||
|
||||
def init_controller(ctrl: DoorController):
|
||||
global _contoller
|
||||
_contoller = ctrl
|
||||
|
||||
|
||||
def get_controller():
|
||||
if _contoller is None:
|
||||
raise RuntimeError("DoorController not initialized.")
|
||||
return _contoller
|
||||
|
||||
|
||||
def openDoor():
|
||||
global doorIsOpen
|
||||
doorIsOpen = True
|
||||
logger.info("Still needs gpio out")
|
||||
get_controller().open()
|
||||
|
||||
|
||||
def closeDoor():
|
||||
global doorIsOpen
|
||||
doorIsOpen = False
|
||||
logger.info("Still needs gpio out")
|
||||
get_controller().close()
|
||||
|
||||
|
||||
def isDoorOpen():
|
||||
return doorIsOpen
|
||||
return get_controller().is_open()
|
||||
|
||||
|
||||
def decrementOneshot(db: Session, oneshot: OneShotAccess):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
@@ -25,15 +24,16 @@ from smartcard.CardRequest import CardRequest
|
||||
from smartcard.CardType import AnyCardType
|
||||
from smartcard.Exceptions import CardRequestTimeoutException
|
||||
|
||||
from app.services.door import checkAccess, openDoor
|
||||
from app.services.door import checkAccess, closeDoor, isDoorOpen, openDoor
|
||||
from app.services.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ENV vars
|
||||
load_dotenv()
|
||||
MIFARE_APP_MASTER_KEY = os.getenv("MIFARE_APP_MASTER_KEY")
|
||||
MIFARE_ACL_READ_BASE_KEY = os.getenv("MIFARE_ACL_READ_BASE_KEY")
|
||||
MIFARE_ACL_WRITE_BASE_KEY = os.getenv("MIFARE_ACL_WRITE_BASE_KEY")
|
||||
MIFARE_APP_MASTER_KEY = settings.mifare_app_master_key
|
||||
MIFARE_ACL_READ_BASE_KEY = settings.mifare_acl_read_base_key
|
||||
MIFARE_ACL_WRITE_BASE_KEY = settings.mifare_acl_write_base_key
|
||||
|
||||
# Constants
|
||||
MIFARE_APP_ID = "DEAFFE" # 7 bytes
|
||||
@@ -332,9 +332,13 @@ class BackgroundScanner:
|
||||
time.sleep(5)
|
||||
|
||||
def _check_db(self, key):
|
||||
check = checkAccess(key, self.db)
|
||||
if check:
|
||||
openDoor()
|
||||
logger.info("Access granted!")
|
||||
if isDoorOpen():
|
||||
closeDoor()
|
||||
logger.info("Door closed by key %s", key)
|
||||
else:
|
||||
logger.error("Access denied!")
|
||||
check = checkAccess(key, self.db)
|
||||
if check:
|
||||
openDoor()
|
||||
logger.info("Access granted!")
|
||||
else:
|
||||
logger.error("Access denied!")
|
||||
|
||||
@@ -1,29 +1,70 @@
|
||||
import logging
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def verify_settings():
|
||||
card_envs = [
|
||||
"MIFARE_APP_MASTER_KEY",
|
||||
"MIFARE_ACL_READ_BASE_KEY",
|
||||
"MIFARE_ACL_WRITE_BASE_KEY",
|
||||
]
|
||||
important_envs = ["SECRET_KEY"]
|
||||
other_envs = ["SQLALCHEMY_DATABASE_PATH"]
|
||||
for setting in card_envs:
|
||||
if (setting not in os.environ or setting == "") and not os.getenv(
|
||||
"DISABLE_CARDS"
|
||||
):
|
||||
raise ValueError(
|
||||
f"Missing environment variable for scanner start: {setting} \n Run with DISABLE_CARDS env var to disable cards" # noqa: E501
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
|
||||
secret_key: str
|
||||
sqlalchemy_database_url: str = "sqlite:///./gatekeeper.db"
|
||||
lock_pin: int = 17
|
||||
unlock_pin: int = 18
|
||||
mock_gpio: bool = True
|
||||
alembic_config: str = "./alembic.ini"
|
||||
|
||||
disable_cards: bool = False
|
||||
mifare_app_master_key: str | None = None
|
||||
mifare_acl_read_base_key: str | None = None
|
||||
mifare_acl_write_base_key: str | None = None
|
||||
|
||||
|
||||
@lru_cache(1)
|
||||
def _create_settings():
|
||||
settings = Settings()
|
||||
if not settings.disable_cards:
|
||||
missing = [
|
||||
name.upper()
|
||||
for name in (
|
||||
"mifare_app_master_key",
|
||||
"mifare_acl_read_base_key",
|
||||
"mifare_acl_write_base_key",
|
||||
)
|
||||
for setting in important_envs:
|
||||
if setting not in os.environ or setting == "":
|
||||
raise ValueError(
|
||||
f"Missing critical environment variable {setting}. Stopping..."
|
||||
if not getattr(settings, name)
|
||||
]
|
||||
if missing:
|
||||
logger.critical(
|
||||
"Missing environment variable for scanner start: %s"
|
||||
"Card scanner and related funcionality is disabled!",
|
||||
", ".join(missing),
|
||||
)
|
||||
for setting in other_envs:
|
||||
if setting not in os.environ:
|
||||
logger.warning(f"Env var {setting} not set. Continuing with defaults.")
|
||||
settings.disable_cards = True
|
||||
return settings
|
||||
|
||||
|
||||
class _SettingsProxy:
|
||||
_instance: Settings | None = None
|
||||
|
||||
def _load(self) -> Settings:
|
||||
if self._instance is None:
|
||||
self._instance = _create_settings()
|
||||
return self._instance
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self._load(), name)
|
||||
|
||||
def __setattr__(self, name: str, value):
|
||||
if name == "_instance":
|
||||
super().__setattr__(name, value)
|
||||
else:
|
||||
setattr(self._load(), name, value)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._instance = None
|
||||
_create_settings.cache_clear()
|
||||
|
||||
|
||||
settings = _SettingsProxy()
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
buildInputs = (old.buildInputs or []) ++ [ pkgs.pcsclite.dev ];
|
||||
NIX_CFLAGS_COMPILE = "-I${pkgs.pcsclite.dev}/include/PCSC";
|
||||
});
|
||||
lgpio = prev.lgpio.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.swig ];
|
||||
buildInputs = (old.buildInputs or []) ++ [ pkgs.lgpio ];
|
||||
});
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -93,7 +97,7 @@
|
||||
UV_NO_SYNC = "1";
|
||||
UV_PYTHON = pythonSet.python.interpreter;
|
||||
UV_PYTHON_DOWNLOADS = "never";
|
||||
LD_LIBRARY_PATH = "${lib.getLib pkgs.pcsclite}/lib";
|
||||
LD_LIBRARY_PATH = "${lib.getLib pkgs.pcsclite}/lib:${lib.getLib pkgs.lgpio}/lib";
|
||||
};
|
||||
shellHook = ''
|
||||
unset PYTHONPATH
|
||||
|
||||
31
module.nix
31
module.nix
@@ -6,23 +6,42 @@ in
|
||||
options = {
|
||||
services.gatekeeper = {
|
||||
enable = lib.mkEnableOption "Enable the gatekeeper api service.";
|
||||
dotenv = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "The path to a .env file with the keys";
|
||||
envFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
description = "The path to a .env file with all the other options";
|
||||
};
|
||||
db = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Where to save the database.";
|
||||
default = "/var/lib/gatekeeper";
|
||||
};
|
||||
mockGpio = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "True";
|
||||
description = "Mock GPIO pins. Has to be a string!";
|
||||
};
|
||||
};
|
||||
};
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.groups.gatekeeper = {};
|
||||
users.groups.gpio = {};
|
||||
users.users.gatekeeper = {
|
||||
description = "gatekeeper user";
|
||||
group = "gatekeeper";
|
||||
extraGroups = ["gpio"];
|
||||
isSystemUser = true;
|
||||
};
|
||||
services.udev.extraRules = lib.mkBefore ''
|
||||
KERNEL=="gpiomem", GROUP="gpio", MODE="0660"
|
||||
SUBSYSTEM=="gpio", KERNEL=="gpiochip*", ACTION=="add", PROGRAM="${pkgs.bash}/bin/bash -c '${pkgs.coreutils}/bin/chgrp gpio /dev/%k && chmod 660 /dev/%k && ${pkgs.coreutils}/bin/chgrp -R gpio /sys/class/gpio && ${pkgs.coreutils}/bin/chmod -R g=u /sys/class/gpio'"
|
||||
SUBSYSTEM=="gpio", ACTION=="add", PROGRAM="${pkgs.bash}/bin/bash -c '${pkgs.coreutils}/bin/chgrp -R gpio /sys%p && ${pkgs.coreutils}/bin/chmod -R g=u /sys%p'"
|
||||
'';
|
||||
|
||||
boot.kernelParams = [
|
||||
"iomem=relaxed" # for pigpiod
|
||||
"strict-devmem=0"
|
||||
];
|
||||
|
||||
services.pcscd = {
|
||||
enable = true;
|
||||
plugins = [ pkgs.acsccid ];
|
||||
@@ -43,6 +62,12 @@ in
|
||||
RestartSec = "20";
|
||||
StateDirectory = "gatekeeper";
|
||||
WorkingDirectory = "/var/lib/gatekeeper";
|
||||
EnvironmentFile = cfg.envFile;
|
||||
};
|
||||
environment = {
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///${cfg.db}/gatekeeper.db";
|
||||
ALEMBIC_CONFIG = "${self}/alembic.ini";
|
||||
MOCK_GPIO = cfg.mockGpio;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ dependencies = [
|
||||
"pyscard>=2.3.1",
|
||||
"alembic>=1.18.5",
|
||||
"ruff>=0.16.0",
|
||||
"pydantic-settings>=2.13.1",
|
||||
'lgpio>=0.2.2.0' # ; sys_platform == "linux" and platform_machine == "aarch64"',
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
@@ -26,6 +28,7 @@ python-desfire = { git = "https://github.com/waza-ari/python-desfire" }
|
||||
[tool.uv.extra-build-dependencies]
|
||||
python-desfire = ["poetry"]
|
||||
"pyscard" = ["setuptools"]
|
||||
"lgpio" = ["setuptools"]
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["app"]
|
||||
@@ -47,4 +50,4 @@ select = [
|
||||
# isort
|
||||
"I",
|
||||
]
|
||||
ignore = ["B008"]
|
||||
ignore = ["B008"]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from datetime import time
|
||||
|
||||
import pytest
|
||||
@@ -5,6 +6,8 @@ from fastapi.testclient import TestClient
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
os.environ["SECRET_KEY"] = "ff" * 16
|
||||
|
||||
from app.main import app
|
||||
from app.model.models import (
|
||||
AccessAuthorizationDB,
|
||||
|
||||
@@ -9,10 +9,10 @@ def test_create_db_and_tables():
|
||||
# This is primarily an integration test
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from app.services.database import engine
|
||||
from app.services.database import get_engine
|
||||
|
||||
create_db_and_tables()
|
||||
inspector = inspect(engine)
|
||||
inspector = inspect(get_engine())
|
||||
|
||||
# Check that tables exist
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
10
uv.lock
generated
10
uv.lock
generated
@@ -624,8 +624,10 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "lgpio" },
|
||||
{ name = "poetry" },
|
||||
{ name = "pwdlib", extra = ["argon2"] },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "pyscard" },
|
||||
{ name = "pytest" },
|
||||
@@ -641,8 +643,10 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.18.5" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" },
|
||||
{ name = "lgpio", specifier = ">=0.2.2.0" },
|
||||
{ name = "poetry", specifier = ">=2.3.4" },
|
||||
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.13.1" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" },
|
||||
{ name = "pyscard", specifier = ">=2.3.1" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
@@ -842,6 +846,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lgpio"
|
||||
version = "0.2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/33/26ec2e8049eaa2f077bf23a12dc61ca559fbfa7bea0516bf263d657ae275/lgpio-0.2.2.0.tar.gz", hash = "sha256:11372e653b200f76a0b3ef8a23a0735c85ec678a9f8550b9893151ed0f863fff", size = 90087, upload-time = "2024-03-29T21:59:55.901Z" }
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.12"
|
||||
|
||||
Reference in New Issue
Block a user