Compare commits

..

No commits in common. "69a1aa247192d52fa044159fd181e270e681e63f" and "6ad7c29a4851754e86a9ddf15acac69ba0b8528e" have entirely different histories.

22 changed files with 1614 additions and 1012 deletions

View File

@ -16,37 +16,6 @@ alembic upgrade head
# Rollback the migration # Rollback the migration
alembic downgrade head alembic downgrade head
``` ```
## Setup tests
```sh
nix build .#postgresql-devel
podman load < result
podman run -p 54320:5432 --name database -dt postgresql:latest
nix build .#redis-devel
podman load < result
podman run -p 63790:63790 --name cache -dt redis:latest
nix develop
pdm install --dev
eval $(pdm venv activate)
pytest
```
## Side notes
```
/var
/lib
/materia <-- data directory
/repository <-- repository directory
/rick <-- user name
/default <--| default repository name
... | possible features: external cloud drives?
/first <-- first level directories counts as root because no parent
/nested
/hello.txt
```
# License # License
**materia** is licensed under the [MIT License](LICENSE). **materia** is licensed under the [MIT License](LICENSE).

File diff suppressed because it is too large Load Diff

View File

@ -51,7 +51,7 @@
... ...
}: { }: {
name = "materia-frontend"; name = "materia-frontend";
version = "0.0.5"; version = "0.0.1";
imports = [ imports = [
dream2nix.modules.dream2nix.WIP-nodejs-builder-v3 dream2nix.modules.dream2nix.WIP-nodejs-builder-v3
@ -94,20 +94,20 @@
}: { }: {
imports = [dream2nix.modules.dream2nix.WIP-python-pdm]; imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
pdm.lockfile = ./workspaces/frontend/pdm.lock; pdm.lockfile = ./materia-web-client/pdm.lock;
pdm.pyproject = ./workspaces/frontend/pyproject.toml; pdm.pyproject = ./materia-web-client/pyproject.toml;
deps = _: { deps = _: {
python = pkgs.python312; python = pkgs.python3;
}; };
mkDerivation = { mkDerivation = {
src = ./workspaces/frontend; src = ./workspaces/frontend;
buildInputs = [ buildInputs = [
pkgs.python312.pkgs.pdm-backend pkgs.python3.pkgs.pdm-backend
]; ];
configurePhase = '' configurePhase = ''
cp -rv ${materia-frontend-nodejs}/dist ./src/materia_frontend/ cp -rv ${materia-frontend-nodejs}/dist ./src/materia-frontend/
''; '';
}; };
}; };
@ -119,10 +119,7 @@
}; };
}; };
materia = dreamBuildPackage { materia-server = dreamBuildPackage {
extraArgs = {
inherit (self.packages.x86_64-linux) materia-frontend;
};
module = { module = {
config, config,
lib, lib,
@ -132,23 +129,20 @@
}: { }: {
imports = [dream2nix.modules.dream2nix.WIP-python-pdm]; imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
pdm.lockfile = ./pdm.lock; pdm.lockfile = ./materia-server/pdm.lock;
pdm.pyproject = ./pyproject.toml; pdm.pyproject = ./materia-server/pyproject.toml;
deps = _: { deps = _: {
python = pkgs.python312; python = pkgs.python3;
}; };
mkDerivation = { mkDerivation = {
src = ./.; src = ./materia-server;
buildInputs = [ buildInputs = [
pkgs.python312.pkgs.pdm-backend pkgs.python3.pkgs.pdm-backend
]; ];
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.python312.pkgs.wrapPython pkgs.python3.pkgs.wrapPython
];
propagatedBuildInputs = [
materia-frontend
]; ];
}; };
}; };
@ -157,7 +151,7 @@
license = licenses.mit; license = licenses.mit;
maintainers = with bonLib.maintainers; [L-Nafaryus]; maintainers = with bonLib.maintainers; [L-Nafaryus];
broken = false; broken = false;
mainProgram = "materia"; mainProgram = "materia-server";
}; };
}; };
@ -166,8 +160,15 @@
redis-devel = bonfire.packages.x86_64-linux.redis; redis-devel = bonfire.packages.x86_64-linux.redis;
}; };
apps.x86_64-linux = {
materia-server = {
type = "app";
program = "${self.packages.x86_64-linux.materia-server}/bin/materia-server";
};
};
devShells.x86_64-linux.default = pkgs.mkShell { devShells.x86_64-linux.default = pkgs.mkShell {
buildInputs = with pkgs; [postgresql redis pdm nodejs python312]; buildInputs = with pkgs; [postgresql redis pdm nodejs];
# greenlet requires libstdc++ # greenlet requires libstdc++
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [pkgs.stdenv.cc.cc]; LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [pkgs.stdenv.cc.cc];
}; };

807
pdm.lock

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,7 @@ dependencies = [
"sqids<1.0.0,>=0.4.1", "sqids<1.0.0,>=0.4.1",
"alembic<2.0.0,>=1.13.1", "alembic<2.0.0,>=1.13.1",
"authlib<2.0.0,>=1.3.0", "authlib<2.0.0,>=1.3.0",
"cryptography<43.0.0,>=42.0.7",
"redis[hiredis]<6.0.0,>=5.0.4", "redis[hiredis]<6.0.0,>=5.0.4",
"aiosmtplib<4.0.0,>=3.0.1", "aiosmtplib<4.0.0,>=3.0.1",
"emails<1.0,>=0.6", "emails<1.0,>=0.6",
@ -30,12 +31,9 @@ dependencies = [
"alembic-postgresql-enum<2.0.0,>=1.2.0", "alembic-postgresql-enum<2.0.0,>=1.2.0",
"gunicorn>=22.0.0", "gunicorn>=22.0.0",
"uvicorn-worker>=0.2.0", "uvicorn-worker>=0.2.0",
"httpx>=0.27.0", "httpx>=0.27.0"
"cryptography>=43.0.0",
"python-multipart>=0.0.9",
"jinja2>=3.1.4",
] ]
requires-python = ">=3.12,<3.13" requires-python = "<3.12,>=3.10"
readme = "README.md" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}
@ -71,9 +69,10 @@ includes = ["src/materia"]
[tool.pdm.scripts] [tool.pdm.scripts]
start.cmd = "python ./src/materia/main.py {args:start --app-mode development --log-level debug}" start.cmd = "python ./src/materia/main.py {args:start --app-mode development --log-level debug}"
setup.cmd = "psql -U postgres -h 127.0.0.1 -p 54320 -d postgres -c 'create role materia login;' -c 'create database materia owner materia;'" upgrade.cmd = "alembic -c ./src/materia/alembic.ini upgrade {args:head}"
teardown.cmd = "psql -U postgres -h 127.0.0.1 -p 54320 -d postgres -c 'drop database materia;' -c 'drop role materia;'" downgrade.shell = "alembic -c ./src/materia/alembic.ini downgrade {args:base}"
rev.cmd = "alembic revision {args:--autogenerate}" rev.cmd = "alembic revision {args:--autogenerate}"
upgrade.cmd = "alembic upgrade {args:head}" rm-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py"
downgrade.cmd = "alembic downgrade {args:base}"
remove-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py"

View File

@ -66,13 +66,6 @@ def make_lifespan(config: Config, logger: Logger):
def make_application(config: Config, logger: Logger): def make_application(config: Config, logger: Logger):
try:
import materia_frontend
except ModuleNotFoundError:
logger.warning(
"`materia_frontend` is not installed. No user interface will be served."
)
app = FastAPI( app = FastAPI(
title="materia", title="materia",
version="0.1.0", version="0.1.0",

View File

@ -20,7 +20,6 @@ from materia.models.repository import (
Repository, Repository,
RepositoryInfo, RepositoryInfo,
RepositoryContent, RepositoryContent,
RepositoryError,
) )
from materia.models.directory import Directory, DirectoryLink, DirectoryInfo from materia.models.directory import Directory, DirectoryLink, DirectoryInfo

View File

@ -1,9 +1,2 @@
from materia.models.database.database import ( from materia.models.database.database import DatabaseError, DatabaseMigrationError, Database
DatabaseError,
DatabaseMigrationError,
Database,
SessionMaker,
SessionContext,
ConnectionContext,
)
from materia.models.database.cache import Cache, CacheError from materia.models.database.cache import Cache, CacheError

View File

@ -1,6 +1,6 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import os import os
from typing import AsyncIterator, Self, TypeAlias from typing import AsyncIterator, Self
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, PostgresDsn from pydantic import BaseModel, PostgresDsn
@ -17,8 +17,6 @@ from alembic.config import Config as AlembicConfig
from alembic.operations import Operations from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from alembic.script.base import ScriptDirectory from alembic.script.base import ScriptDirectory
import alembic_postgresql_enum
from fastapi import HTTPException
from materia.config import Config from materia.config import Config
from materia.models.base import Base from materia.models.base import Base
@ -34,21 +32,16 @@ class DatabaseMigrationError(Exception):
pass pass
SessionContext: TypeAlias = AsyncIterator[AsyncSession]
SessionMaker: TypeAlias = async_sessionmaker[AsyncSession]
ConnectionContext: TypeAlias = AsyncIterator[AsyncConnection]
class Database: class Database:
def __init__( def __init__(
self, self,
url: PostgresDsn, url: PostgresDsn,
engine: AsyncEngine, engine: AsyncEngine,
sessionmaker: SessionMaker, sessionmaker: async_sessionmaker[AsyncSession],
): ):
self.url: PostgresDsn = url self.url: PostgresDsn = url
self.engine: AsyncEngine = engine self.engine: AsyncEngine = engine
self.sessionmaker: SessionMaker = sessionmaker self.sessionmaker: async_sessionmaker[AsyncSession] = sessionmaker
@staticmethod @staticmethod
async def new( async def new(
@ -88,7 +81,7 @@ class Database:
await self.engine.dispose() await self.engine.dispose()
@asynccontextmanager @asynccontextmanager
async def connection(self) -> ConnectionContext: async def connection(self) -> AsyncIterator[AsyncConnection]:
async with self.engine.connect() as connection: async with self.engine.connect() as connection:
try: try:
yield connection yield connection
@ -97,7 +90,7 @@ class Database:
raise DatabaseError(f"{e}") raise DatabaseError(f"{e}")
@asynccontextmanager @asynccontextmanager
async def session(self) -> SessionContext: async def session(self) -> AsyncIterator[AsyncSession]:
session = self.sessionmaker() session = self.sessionmaker()
try: try:
@ -105,9 +98,6 @@ class Database:
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
raise DatabaseError(f"{e}") raise DatabaseError(f"{e}")
except HTTPException:
# if the initial exception reaches HTTPException, then everything is handled fine (should be)
await session.rollback()
finally: finally:
await session.close() await session.close()

View File

@ -1,7 +1,6 @@
from time import time from time import time
from typing import List, Optional, Self from typing import List, Optional, Self
from pathlib import Path from pathlib import Path
import shutil
from sqlalchemy import BigInteger, ForeignKey from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship from sqlalchemy.orm import mapped_column, Mapped, relationship
@ -10,11 +9,6 @@ from pydantic import BaseModel, ConfigDict
from materia.models.base import Base from materia.models.base import Base
from materia.models import database from materia.models import database
from materia.models.database import SessionContext
class DirectoryError(Exception):
pass
class Directory(Base): class Directory(Base):
@ -30,6 +24,7 @@ class Directory(Base):
created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time) created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time) updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
name: Mapped[str] name: Mapped[str]
path: Mapped[str] = mapped_column(nullable=True)
is_public: Mapped[bool] = mapped_column(default=False) is_public: Mapped[bool] = mapped_column(default=False)
repository: Mapped["Repository"] = relationship(back_populates="directories") repository: Mapped["Repository"] = relationship(back_populates="directories")
@ -40,198 +35,32 @@ class Directory(Base):
files: Mapped[List["File"]] = relationship(back_populates="parent") files: Mapped[List["File"]] = relationship(back_populates="parent")
link: Mapped["DirectoryLink"] = relationship(back_populates="directory") link: Mapped["DirectoryLink"] = relationship(back_populates="directory")
async def new(
self,
session: SessionContext,
path: Optional[Path] = None,
with_parents: bool = False,
) -> Optional[Self]:
session.add(self)
await session.flush()
await session.refresh(self, attribute_names=["repository", "parent"])
repository_path: Path = await self.repository.path(session)
current_path: Path = repository_path
current_directory: Optional[Directory] = None
for part in path.parts:
current_path /= part
relative_path = current_path.relative_to(repository_path)
if current_path.exists() and current_path.is_dir():
# Find record
current_directory = await Directory.find(
self.repository, self.parent, self.name, session
)
if not current_directory:
# TODO: recreate record
raise DirectoryError(
f"No directory was found in the records: {relative_path}"
)
current_directory.updated = time()
await session.flush()
continue
if not with_parents:
raise DirectoryError(f"Directory not exists at /{relative_path}")
# Create an ancestor directory from scratch
current_directory = await Directory(
repository_id=self.repository.id,
parent_id=current_directory.id if current_directory else None,
name=part,
).new(
session,
path=relative_path,
with_parents=False,
)
try:
current_path.mkdir()
except OSError as e:
raise DirectoryError(
f"Failed to create directory at /{relative_path}:", *e.args
)
# Create directory
current_path /= self.name
relative_path = current_path.relative_to(repository_path)
try:
current_path.mkdir()
except OSError as e:
raise DirectoryError(
f"Failed to create directory at /{relative_path}:", *e.args
)
# Update information
self.parent = current_directory
await session.commit()
return self
async def remove(self, session: SessionContext):
session.add(self)
current_path: Path = self.repository.path(session) / self.path(session)
try:
shutil.tmtree(str(current_path))
except OSError as e:
raise DirectoryError("Failed to remove directory:", *e.args)
await session.refresh(self, attribute_names=["parent"])
current_directory: Directory = self.parent
while current_directory:
current_directory.updated = time()
session.add(current_directory)
await session.refresh(self, attribute_names=["parent"])
current_directory = current_directory.parent
await session.delete(self)
await session.commit()
async def is_root(self) -> bool:
return self.parent_id is None
@staticmethod @staticmethod
async def find( async def by_path(
repository: "Repository", repository_id: int, path: Path | None, name: str, db: database.Database
directory: "Directory", ) -> Self | None:
name: str,
session: SessionContext,
) -> Optional[Self]:
return (
await session.scalars(
sa.select(Directory).where(
sa.and_(
Directory.repository_id == repository.id,
Directory.name == name,
Directory.parent_id == directory.parent_id,
)
)
)
).first()
async def find_nested(self, session: SessionContext):
pass
async def find_by_descend(
self, path: Path | str, db: database.Database, need_create: bool = False
) -> Optional[Self]:
"""Find a nested directory from current"""
repository_id = self.repository_id
path = Path(path)
current_directory = self
async with db.session() as session: async with db.session() as session:
for part in path.parts: query_path = (
directory = ( Directory.path == str(path)
await session.scalars( if isinstance(path, Path)
sa.select(Directory).where( else Directory.path.is_(None)
sa.and_( )
Directory.repository_id == repository_id, return (
Directory.name == part, await session.scalars(
Directory.parent_id == current_directory.id, sa.select(Directory).where(
) sa.and_(
Directory.repository_id == repository_id,
Directory.name == name,
query_path,
) )
) )
).first() )
).first()
if directory is None: async def remove(self, db: database.Database):
if not need_create: async with db.session() as session:
return None await session.delete(self)
await session.commit()
directory = Directory(
repository_id=repository_id,
parent_id=current_directory.id,
name=part,
)
session.add(directory)
await session.flush()
current_directory = directory
if need_create:
await session.commit()
return current_directory
@staticmethod
async def find_by_path(
repository_id: int, path: Path | str, db: database.Database
) -> Optional[Self]:
"""Find a directory by given absolute path"""
path = Path(path)
assert path == Path(), "The path cannot be empty"
root = await Directory.find_by_descend(repository_id, path.parts[0], db)
return root.descend(Path().joinpath(*path.parts[1:]), db)
async def path(self, session: SessionContext) -> Optional[Path]:
"""Get relative path of the current directory"""
parts = []
current_directory = self
while True:
parts.append(current_directory.name)
session.add(current_directory)
await session.refresh(current_directory, attribute_names=["parent"])
if current_directory.parent is None:
break
current_directory = current_directory.parent
return Path().joinpath(*reversed(parts))
async def info(self) -> "DirectoryInfo":
return DirectoryInfo.model_validate(self)
class DirectoryLink(Base): class DirectoryLink(Base):

View File

@ -0,0 +1,36 @@
"""empty message
Revision ID: 86dd738cbd40
Revises: 939b37d98be0
Create Date: 2024-07-05 16:42:31.645410
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '86dd738cbd40'
down_revision: Union[str, None] = '939b37d98be0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('file_parent_id_fkey', 'file', type_='foreignkey')
op.create_foreign_key(None, 'file', 'directory', ['parent_id'], ['id'], ondelete='CASCADE')
op.drop_constraint('repository_user_id_fkey', 'repository', type_='foreignkey')
op.create_foreign_key(None, 'repository', 'user', ['user_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'repository', type_='foreignkey')
op.create_foreign_key('repository_user_id_fkey', 'repository', 'user', ['user_id'], ['id'])
op.drop_constraint(None, 'file', type_='foreignkey')
op.create_foreign_key('file_parent_id_fkey', 'file', 'directory', ['parent_id'], ['id'])
# ### end Alembic commands ###

View File

@ -1,8 +1,8 @@
"""empty message """empty message
Revision ID: bf2ef6c7ab70 Revision ID: 939b37d98be0
Revises: Revises:
Create Date: 2024-08-02 18:37:01.697075 Create Date: 2024-06-24 15:39:38.380581
""" """
from typing import Sequence, Union from typing import Sequence, Union
@ -12,7 +12,7 @@ import sqlalchemy as sa
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'bf2ef6c7ab70' revision: str = '939b37d98be0'
down_revision: Union[str, None] = None down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -65,7 +65,7 @@ def upgrade() -> None:
sa.Column('id', sa.BigInteger(), nullable=False), sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('capacity', sa.BigInteger(), nullable=False), sa.Column('capacity', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_table('directory', op.create_table('directory',
@ -75,6 +75,7 @@ def upgrade() -> None:
sa.Column('created', sa.BigInteger(), nullable=False), sa.Column('created', sa.BigInteger(), nullable=False),
sa.Column('updated', sa.BigInteger(), nullable=False), sa.Column('updated', sa.BigInteger(), nullable=False),
sa.Column('name', sa.String(), nullable=False), sa.Column('name', sa.String(), nullable=False),
sa.Column('path', sa.String(), nullable=True),
sa.Column('is_public', sa.Boolean(), nullable=False), sa.Column('is_public', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
@ -109,7 +110,7 @@ def upgrade() -> None:
sa.Column('path', sa.String(), nullable=True), sa.Column('path', sa.String(), nullable=True),
sa.Column('is_public', sa.Boolean(), nullable=False), sa.Column('is_public', sa.Boolean(), nullable=False),
sa.Column('size', sa.BigInteger(), nullable=False), sa.Column('size', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )

View File

@ -1,8 +1,6 @@
from time import time from time import time
from typing import List, Self, Optional from typing import List, Self, Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pathlib import Path
import shutil
from sqlalchemy import BigInteger, ForeignKey from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship from sqlalchemy.orm import mapped_column, Mapped, relationship
@ -12,12 +10,6 @@ from pydantic import BaseModel, ConfigDict
from materia.models.base import Base from materia.models.base import Base
from materia.models import database from materia.models import database
from materia.models.database import SessionContext
from materia.config import Config
class RepositoryError(Exception):
pass
class Repository(Base): class Repository(Base):
@ -31,57 +23,6 @@ class Repository(Base):
directories: Mapped[List["Directory"]] = relationship(back_populates="repository") directories: Mapped[List["Directory"]] = relationship(back_populates="repository")
files: Mapped[List["File"]] = relationship(back_populates="repository") files: Mapped[List["File"]] = relationship(back_populates="repository")
async def new(self, session: SessionContext, config: Config) -> Optional[Self]:
session.add(self)
await session.flush()
repository_path = await self.path(session, config)
try:
repository_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise RepositoryError(
f"Failed to create repository at /{repository_path.relative_to(config.application.working_directory)}:",
*e.args,
)
await session.flush()
return self
async def path(self, session: SessionContext, config: Config) -> Path:
session.add(self)
await session.refresh(self, attribute_names=["user"])
repository_path = config.application.working_directory.joinpath(
"repository", self.user.lower_name, "default"
)
return repository_path
async def remove(self, session: SessionContext, config: Config):
session.add(self)
await session.refresh(self, attribute_names=["directories", "files"])
for directory in self.directories:
if directory.is_root():
await directory.remove(session)
for file in self.files:
await file.remove(session)
repository_path = await self.path(session, config)
try:
shutil.rmtree(str(repository_path))
except OSError as e:
raise RepositoryError(
f"Failed to remove repository at /{repository_path.relative_to(config.application.working_directory)}:",
*e.args,
)
await session.delete(self)
await session.flush()
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
k: getattr(self, k) k: getattr(self, k)
@ -89,20 +30,33 @@ class Repository(Base):
if isinstance(v, InstrumentedAttribute) if isinstance(v, InstrumentedAttribute)
} }
async def update(self, session: SessionContext): async def create(self, db: database.Database):
await session.execute( async with db.session() as session:
sa.update(Repository).values(self.to_dict()).where(Repository.id == self.id) session.add(self)
) await session.commit()
await session.flush()
async def update(self, db: database.Database):
async with db.session() as session:
await session.execute(
sa.update(Repository)
.where(Repository.id == self.id)
.values(self.to_dict())
)
await session.commit()
@staticmethod @staticmethod
async def from_user(user: "User", session: SessionContext) -> Optional[Self]: async def by_user_id(user_id: UUID, db: database.Database) -> Self | None:
session.add(user) async with db.session() as session:
await session.refresh(user, attribute_names=["repository"]) return (
return user.repository await session.scalars(
sa.select(Repository).where(Repository.user_id == user_id)
)
).first()
async def info(self) -> "RepositoryInfo": async def remove(self, db: database.Database):
return RepositoryInfo.model_validate(self) async with db.session() as session:
await session.delete(self)
await session.commit()
class RepositoryInfo(BaseModel): class RepositoryInfo(BaseModel):

View File

@ -1,5 +1,5 @@
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from typing import Optional, Self from typing import Optional
import time import time
import re import re
@ -9,22 +9,15 @@ from sqlalchemy import BigInteger, Enum
from sqlalchemy.orm import mapped_column, Mapped, relationship from sqlalchemy.orm import mapped_column, Mapped, relationship
import sqlalchemy as sa import sqlalchemy as sa
from materia import security
from materia.models.base import Base from materia.models.base import Base
from materia.models.auth.source import LoginType from materia.models.auth.source import LoginType
from materia.models import database from materia.models import database
from materia.models.database import SessionContext
from materia.config import Config
from loguru import logger from loguru import logger
valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$") valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$")
invalid_username = re.compile(r"[-._]{2,}|[-._]$") invalid_username = re.compile(r"[-._]{2,}|[-._]$")
class UserError(Exception):
pass
class User(Base): class User(Base):
__tablename__ = "user" __tablename__ = "user"
@ -50,23 +43,6 @@ class User(Base):
repository: Mapped["Repository"] = relationship(back_populates="user") repository: Mapped["Repository"] = relationship(back_populates="user")
async def new(self, session: SessionContext, config: Config) -> Optional[Self]:
# Provide checks outer
session.add(self)
await session.flush()
return self
async def remove(self, session: SessionContext):
session.add(self)
await session.refresh(self, attribute_names=["repository"])
if self.repository:
await self.repository.remove()
await session.delete(self)
await session.flush()
def update_last_login(self): def update_last_login(self):
self.last_login = int(time.time()) self.last_login = int(time.time())
@ -77,74 +53,37 @@ class User(Base):
return self.login_type == LoginType.OAuth2 return self.login_type == LoginType.OAuth2
@staticmethod @staticmethod
def check_username(name: str) -> bool: def is_valid_username(name: str) -> bool:
return bool(valid_username.match(name) and not invalid_username.match(name)) return bool(valid_username.match(name) and not invalid_username.match(name))
@staticmethod @staticmethod
def check_password(password: str, config: Config) -> bool: async def count(db: database.Database):
if len(password) < config.security.password_min_length: async with db.session() as session:
return False return await session.scalar(sa.select(sa.func.count(User.id)))
@staticmethod @staticmethod
async def count(session: SessionContext) -> Optional[int]: async def by_name(name: str, db: database.Database):
return await session.scalar(sa.select(sa.func.count(User.id))) async with db.session() as session:
return (
await session.scalars(sa.select(User).where(User.name == name))
).first()
@staticmethod @staticmethod
async def by_name( async def by_email(email: str, db: database.Database):
name: str, session: SessionContext, with_lower: bool = False async with db.session() as session:
) -> Optional[Self]: return (
if with_lower: await session.scalars(sa.select(User).where(User.email == email))
query = User.lower_name == name.lower() ).first()
else:
query = User.name == name
return (await session.scalars(sa.select(User).where(query))).first()
@staticmethod @staticmethod
async def by_email(email: str, session: SessionContext) -> Optional[Self]: async def by_id(id: UUID, db: database.Database):
return ( async with db.session() as session:
await session.scalars(sa.select(User).where(User.email == email)) return (await session.scalars(sa.select(User).where(User.id == id))).first()
).first()
@staticmethod async def remove(self, db: database.Database):
async def by_id(id: UUID, session: SessionContext) -> Optional[Self]: async with db.session() as session:
return (await session.scalars(sa.select(User).where(User.id == id))).first() await session.delete(self)
await session.commit()
async def edit_name(self, name: str, session: SessionContext) -> Self:
if not User.check_username(name):
raise UserError(f"Invalid username: {name}")
self.name = name
self.lower_name = name.lower()
session.add(self)
await session.flush()
return self
async def edit_password(
self, password: str, session: SessionContext, config: Config
) -> Self:
if not User.check_password(password, config):
raise UserError("Invalid password")
self.hashed_password = security.hash_password(
password, algo=config.security.password_hash_algo
)
session.add(self)
await session.flush()
return self
async def edit_email(self):
pass
def info(self) -> "UserInfo":
user_info = UserInfo.model_validate(self)
if user_info.is_email_private:
user_info.email = None
return user_info
class UserCredentials(BaseModel): class UserCredentials(BaseModel):

View File

@ -1,3 +1,4 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
@ -5,92 +6,77 @@ from materia import security
from materia.routers.middleware import Context from materia.routers.middleware import Context
from materia.models import LoginType, User, UserCredentials from materia.models import LoginType, User, UserCredentials
router = APIRouter(tags=["auth"]) router = APIRouter(tags = ["auth"])
@router.post("/auth/signup") @router.post("/auth/signup")
async def signup(body: UserCredentials, ctx: Context = Depends()): async def signup(body: UserCredentials, ctx: Context = Depends()):
if not User.is_valid_username(body.name):
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = "Invalid username")
if await User.by_name(body.name, ctx.database) is not None:
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = "User already exists")
if await User.by_email(body.email, ctx.database) is not None: # type: ignore
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = "Email already used")
if len(body.password) < ctx.config.security.password_min_length:
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = f"Password is too short (minimum length {ctx.config.security.password_min_length})")
count: Optional[int] = await User.count(ctx.database)
new_user = User(
name = body.name,
lower_name = body.name.lower(),
full_name = body.name,
email = body.email,
hashed_password = security.hash_password(body.password, algo = ctx.config.security.password_hash_algo),
login_type = LoginType.Plain,
# first registered user is admin
is_admin = count == 0
)
async with ctx.database.session() as session: async with ctx.database.session() as session:
if not User.check_username(body.name): session.add(new_user)
raise HTTPException( await session.commit()
status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid username"
)
if not User.check_password(body.password, ctx.config):
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Password is too short (minimum length {ctx.config.security.password_min_length})",
)
if await User.by_name(body.name, session, with_lower=True):
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User already exists"
)
if await User.by_email(body.email, session): # type: ignore
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Email already used"
)
count: Optional[int] = await User.count(session)
await User(
name=body.name,
lower_name=body.name.lower(),
full_name=body.name,
email=body.email,
hashed_password=security.hash_password(
body.password, algo=ctx.config.security.password_hash_algo
),
login_type=LoginType.Plain,
# first registered user is admin
is_admin=count == 0,
).new(session)
@router.post("/auth/signin") @router.post("/auth/signin")
async def signin(body: UserCredentials, response: Response, ctx: Context = Depends()): async def signin(body: UserCredentials, response: Response, ctx: Context = Depends()):
if (current_user := await User.by_name(body.name, ctx.database)) is None: if (current_user := await User.by_name(body.name, ctx.database)) is None:
if (current_user := await User.by_email(str(body.email), ctx.database)) is None: if (current_user := await User.by_email(str(body.email), ctx.database)) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid email") raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid email")
if not security.validate_password( if not security.validate_password(body.password, current_user.hashed_password, algo = ctx.config.security.password_hash_algo):
body.password, raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid password")
current_user.hashed_password,
algo=ctx.config.security.password_hash_algo,
):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
issuer = "{}://{}".format(ctx.config.server.scheme, ctx.config.server.domain) issuer = "{}://{}".format(ctx.config.server.scheme, ctx.config.server.domain)
secret = ( secret = ctx.config.oauth2.jwt_secret if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"] else ctx.config.oauth2.jwt_signing_key
ctx.config.oauth2.jwt_secret
if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"]
else ctx.config.oauth2.jwt_signing_key
)
access_token = security.generate_token( access_token = security.generate_token(
str(current_user.id), str(current_user.id),
str(secret), str(secret),
ctx.config.oauth2.access_token_lifetime, ctx.config.oauth2.access_token_lifetime,
issuer, issuer
) )
refresh_token = security.generate_token( refresh_token = security.generate_token(
"", str(secret), ctx.config.oauth2.refresh_token_lifetime, issuer "",
str(secret),
ctx.config.oauth2.refresh_token_lifetime,
issuer
) )
response.set_cookie( response.set_cookie(
ctx.config.security.cookie_access_token_name, ctx.config.security.cookie_access_token_name,
value=access_token, value = access_token,
max_age=ctx.config.oauth2.access_token_lifetime, max_age = ctx.config.oauth2.access_token_lifetime,
secure=True, secure = True,
httponly=ctx.config.security.cookie_http_only, httponly = ctx.config.security.cookie_http_only,
samesite="lax", samesite = "lax"
) )
response.set_cookie( response.set_cookie(
ctx.config.security.cookie_refresh_token_name, ctx.config.security.cookie_refresh_token_name,
value=refresh_token, value = refresh_token,
max_age=ctx.config.oauth2.refresh_token_lifetime, max_age = ctx.config.oauth2.refresh_token_lifetime,
secure=True, secure = True,
httponly=ctx.config.security.cookie_http_only, httponly = ctx.config.security.cookie_http_only,
samesite="lax", samesite = "lax"
) )
@router.get("/auth/signout") @router.get("/auth/signout")
async def signout(response: Response, ctx: Context = Depends()): async def signout(response: Response, ctx: Context = Depends()):
response.delete_cookie(ctx.config.security.cookie_access_token_name) response.delete_cookie(ctx.config.security.cookie_access_token_name)

View File

@ -43,6 +43,7 @@ async def create(
repository_id=user.repository.id, repository_id=user.repository.id,
parent_id=current_directory.id if current_directory else None, parent_id=current_directory.id if current_directory else None,
name=part, name=part,
path=None if current_path == Path() else str(current_path),
) )
try: try:

View File

@ -21,19 +21,21 @@ router = APIRouter(tags=["repository"])
async def create( async def create(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends() user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
): ):
async with ctx.database.session() as session: repository_path = Config.data_dir() / "repository" / user.lower_name
if await Repository.by_user(user, session):
raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists")
async with ctx.database.session() as session: if await Repository.by_user_id(user.id, ctx.database):
try: raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists")
await Repository(
user_id=user.id, capacity=ctx.config.repository.capacity repository = Repository(user_id=user.id, capacity=ctx.config.repository.capacity)
).new(session)
except Exception as e: try:
raise HTTPException( repository_path.mkdir(parents=True, exist_ok=True)
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=" ".join(e.args) except OSError:
) raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a repository"
)
await repository.create(ctx.database)
@router.get("/repository", response_model=RepositoryInfo) @router.get("/repository", response_model=RepositoryInfo)

View File

@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.staticfiles import StaticFiles
from PIL import Image from PIL import Image
import io import io
from pathlib import Path from pathlib import Path
@ -6,6 +7,7 @@ import mimetypes
from materia.routers import middleware from materia.routers import middleware
from materia.config import Config from materia.config import Config
import materia_frontend
router = APIRouter(tags=["resources"], prefix="/resources") router = APIRouter(tags=["resources"], prefix="/resources")
@ -39,22 +41,16 @@ async def avatar(
return Response(content=buffer.getvalue(), media_type=Image.MIME[format]) return Response(content=buffer.getvalue(), media_type=Image.MIME[format])
try: @router.get("/assets/{filename}")
import materia_frontend async def assets(filename: str):
except ModuleNotFoundError: path = Path(materia_frontend.__path__[0]).joinpath(
pass "dist", "resources", "assets", filename
else: )
@router.get("/assets/{filename}") if not path.exists():
async def assets(filename: str): return Response(status_code=status.HTTP_404_NOT_FOUND)
path = Path(materia_frontend.__path__[0]).joinpath(
"dist", "resources", "assets", filename
)
if not path.exists(): content = path.read_bytes()
return Response(status_code=status.HTTP_404_NOT_FOUND) mime = mimetypes.guess_type(path)[0]
content = path.read_bytes() return Response(content, media_type=mime)
mime = mimetypes.guess_type(path)[0]
return Response(content, media_type=mime)

View File

@ -2,19 +2,13 @@ from pathlib import Path
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
import materia_frontend
router = APIRouter(tags=["root"]) router = APIRouter(tags=["root"])
templates = Jinja2Templates(directory=Path(materia_frontend.__path__[0]) / "dist")
try:
import materia_frontend
except ModuleNotFoundError:
pass
else:
templates = Jinja2Templates(directory=Path(materia_frontend.__path__[0]) / "dist") @router.get("/{spa:path}", response_class=HTMLResponse)
async def root(request: Request):
@router.get("/{spa:path}", response_class=HTMLResponse) return templates.TemplateResponse("base.html", {"request": request, "view": "app"})
async def root(request: Request):
return templates.TemplateResponse(
"base.html", {"request": request, "view": "app"}
)

View File

@ -1,22 +1,11 @@
import pytest_asyncio import pytest_asyncio
import pytest import pytest
import os import os
from pathlib import Path
from materia.config import Config from materia.config import Config
from materia.models import ( from materia.models import Database, User, LoginType, Repository, Directory
Database,
User,
LoginType,
Repository,
Directory,
RepositoryError,
)
from materia.models.base import Base
from materia.models.database import SessionContext
from materia import security from materia import security
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from sqlalchemy.orm.session import make_transient
from dataclasses import dataclass from dataclasses import dataclass
@ -57,6 +46,7 @@ async def db(config: Config, request) -> Database:
await database.dispose() await database.dispose()
# database_postgres = await Database.new(config_postgres.database.url())
async with database_postgres.connection() as connection: async with database_postgres.connection() as connection:
await connection.execution_options(isolation_level="AUTOCOMMIT") await connection.execution_options(isolation_level="AUTOCOMMIT")
await connection.execute(sa.text("drop database pytest")), await connection.execute(sa.text("drop database pytest")),
@ -65,23 +55,11 @@ async def db(config: Config, request) -> Database:
await database_postgres.dispose() await database_postgres.dispose()
"""
@pytest.mark.asyncio
async def test_migrations(db):
await db.run_migrations()
await db.rollback_migrations()
"""
@pytest_asyncio.fixture(scope="session", autouse=True) @pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_db(db: Database, request): async def setup_db(db: Database, request):
async with db.connection() as connection: await db.run_migrations()
await connection.run_sync(Base.metadata.create_all)
await connection.commit()
yield yield
async with db.connection() as connection: # await db.rollback_migrations()
await connection.run_sync(Base.metadata.drop_all)
await connection.commit()
@pytest_asyncio.fixture(autouse=True) @pytest_asyncio.fixture(autouse=True)
@ -92,7 +70,6 @@ async def session(db: Database, request):
await session.close() await session.close()
"""
@pytest_asyncio.fixture(scope="session") @pytest_asyncio.fixture(scope="session")
async def user(config: Config, session) -> User: async def user(config: Config, session) -> User:
test_user = User( test_user = User(
@ -116,14 +93,13 @@ async def user(config: Config, session) -> User:
async with db.session() as session: async with db.session() as session:
await session.delete(test_user) await session.delete(test_user)
await session.flush() await session.flush()
"""
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture
async def data(config: Config): async def data(config: Config):
class TestData: class TestData:
user = User( user = User(
name="PyTest", name="pytest",
lower_name="pytest", lower_name="pytest",
email="pytest@example.com", email="pytest@example.com",
hashed_password=security.hash_password( hashed_password=security.hash_password(
@ -137,58 +113,16 @@ async def data(config: Config):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_user(data, session: SessionContext, config: Config): async def test_user(data, session):
# simple
session.add(data.user) session.add(data.user)
await session.flush() await session.flush()
assert data.user.id is not None assert data.user.id is not None
assert security.validate_password("iampytest", data.user.hashed_password) assert security.validate_password("iampytest", data.user.hashed_password)
await session.rollback()
# methods
await data.user.new(session, config)
assert data.user.id is not None
assert await data.user.count(session) == 1
assert await User.by_name("PyTest", session) == data.user
assert await User.by_email("pytest@example.com", session) == data.user
await data.user.edit_name("AsyncPyTest", session)
assert await User.by_name("asyncpytest", session, with_lower=True) == data.user
await data.user.remove(session)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_repository(data, tmpdir, session: SessionContext, config: Config): async def test_repository(data, session, config):
config.application.working_directory = Path(tmpdir)
session.add(data.user)
await session.flush()
repository = await Repository(
user_id=data.user.id, capacity=config.repository.capacity
).new(session, config)
assert repository
assert repository.id is not None
assert (await repository.path(session, config)).exists()
assert await Repository.from_user(data.user, session) == repository
await repository.remove(session, config)
make_transient(repository)
session.add(repository)
await session.flush()
with pytest.raises(RepositoryError):
await repository.remove(session, config)
assert not (await repository.path(session, config)).exists()
@pytest.mark.asyncio
async def test_directory(data, tmpdir, session: SessionContext, config: Config):
# setup
session.add(data.user) session.add(data.user)
await session.flush() await session.flush()
@ -196,11 +130,24 @@ async def test_directory(data, tmpdir, session: SessionContext, config: Config):
session.add(repository) session.add(repository)
await session.flush() await session.flush()
directory = Directory(repository_id=repository.id, parent_id=None, name="test1") assert repository.id is not None
@pytest.mark.asyncio
async def test_directory(data, session, config):
session.add(data.user)
await session.flush()
repository = Repository(user_id=data.user.id, capacity=config.repository.capacity)
session.add(repository)
await session.flush()
directory = Directory(
repository_id=repository.id, parent_id=None, name="test1", path=None
)
session.add(directory) session.add(directory)
await session.flush() await session.flush()
# simple
assert directory.id is not None assert directory.id is not None
assert ( assert (
await session.scalars( await session.scalars(
@ -208,16 +155,17 @@ async def test_directory(data, tmpdir, session: SessionContext, config: Config):
sa.and_( sa.and_(
Directory.repository_id == repository.id, Directory.repository_id == repository.id,
Directory.name == "test1", Directory.name == "test1",
Directory.path.is_(None),
) )
) )
) )
).first() == directory ).first() == directory
# nested simple
nested_directory = Directory( nested_directory = Directory(
repository_id=repository.id, repository_id=repository.id,
parent_id=directory.id, parent_id=directory.id,
name="test_nested", name="test_nested",
path="test1",
) )
session.add(nested_directory) session.add(nested_directory)
await session.flush() await session.flush()
@ -229,6 +177,7 @@ async def test_directory(data, tmpdir, session: SessionContext, config: Config):
sa.and_( sa.and_(
Directory.repository_id == repository.id, Directory.repository_id == repository.id,
Directory.name == "test_nested", Directory.name == "test_nested",
Directory.path == "test1",
) )
) )
) )

View File

@ -5,7 +5,7 @@
groups = ["default", "dev"] groups = ["default", "dev"]
strategy = ["cross_platform", "inherit_metadata"] strategy = ["cross_platform", "inherit_metadata"]
lock_version = "4.4.1" lock_version = "4.4.1"
content_hash = "sha256:16bedb3de70622af531e01dee2c2773d108a005caf9fa9d2fbe9042267602ef6" content_hash = "sha256:4122146bc4848501b79e4e97551c7835f5559cc7294aa8ec3161a9c2fd86985f"
[[package]] [[package]]
name = "black" name = "black"
@ -19,12 +19,18 @@ dependencies = [
"packaging>=22.0", "packaging>=22.0",
"pathspec>=0.9.0", "pathspec>=0.9.0",
"platformdirs>=2", "platformdirs>=2",
"tomli>=1.1.0; python_version < \"3.11\"",
"typing-extensions>=4.0.1; python_version < \"3.11\"",
] ]
files = [ files = [
{file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"}, {file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"},
{file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"}, {file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"},
{file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"}, {file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"},
{file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"}, {file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"},
{file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"},
{file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"},
{file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"},
{file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"},
{file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"}, {file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"},
{file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"}, {file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"},
] ]
@ -55,6 +61,18 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
[[package]]
name = "exceptiongroup"
version = "1.2.2"
requires_python = ">=3.7"
summary = "Backport of PEP 654 (exception groups)"
groups = ["dev"]
marker = "python_version < \"3.11\""
files = [
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.0.0" version = "2.0.0"
@ -155,15 +173,41 @@ summary = "pytest: simple powerful testing with Python"
groups = ["dev"] groups = ["dev"]
dependencies = [ dependencies = [
"colorama; sys_platform == \"win32\"", "colorama; sys_platform == \"win32\"",
"exceptiongroup>=1.0.0rc8; python_version < \"3.11\"",
"iniconfig", "iniconfig",
"packaging", "packaging",
"pluggy<2.0,>=0.12", "pluggy<2.0,>=0.12",
"tomli>=1.0.0; python_version < \"3.11\"",
] ]
files = [ files = [
{file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
{file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
] ]
[[package]]
name = "tomli"
version = "2.0.1"
requires_python = ">=3.7"
summary = "A lil' TOML parser"
groups = ["dev"]
marker = "python_version < \"3.11\""
files = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
requires_python = ">=3.8"
summary = "Backported and Experimental Type Hints for Python 3.8+"
groups = ["dev"]
marker = "python_version < \"3.11\""
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]] [[package]]
name = "win32-setctime" name = "win32-setctime"
version = "1.1.0" version = "1.1.0"

View File

@ -8,7 +8,7 @@ authors = [
dependencies = [ dependencies = [
"loguru<1.0.0,>=0.7.2", "loguru<1.0.0,>=0.7.2",
] ]
requires-python = ">=3.12,<3.13" requires-python = "<3.12,>=3.10"
readme = "README.md" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}