Compare commits
No commits in common. "69a1aa247192d52fa044159fd181e270e681e63f" and "6ad7c29a4851754e86a9ddf15acac69ba0b8528e" have entirely different histories.
69a1aa2471
...
6ad7c29a48
31
README.md
31
README.md
@ -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).
|
||||||
|
886
flake.lock
886
flake.lock
File diff suppressed because it is too large
Load Diff
43
flake.nix
43
flake.nix
@ -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];
|
||||||
};
|
};
|
||||||
|
@ -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"
|
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
36
src/materia/models/migrations/versions/86dd738cbd40_.py
Normal file
36
src/materia/models/migrations/versions/86dd738cbd40_.py
Normal 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 ###
|
@ -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')
|
||||||
)
|
)
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
|
||||||
|
@ -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"}
|
|
||||||
)
|
|
||||||
|
@ -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",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -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"
|
||||||
|
@ -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"}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user