Compare commits

..

15 Commits

Author SHA1 Message Date
81a55ba452 Add fixtures; minor changes 2022-10-03 09:17:29 +02:00
cf83e0074e Expand courts/regsitry models 2022-08-29 21:06:33 +02:00
8cd13d8337 Consistently use DEFAULT_PK_TYPE 2022-08-17 15:03:41 +02:00
284986f763 Add court models 2022-08-16 21:53:17 +02:00
b417ac16e0 Add indices 2022-08-14 11:44:42 +02:00
3f6be9adbc Add some models; minor changes 2022-08-11 12:34:16 +02:00
9f487d515d Write a few SQLModels;
set up two basic CRUD-like functions;
create two test routes;
make `db_uri` setting more flexible
2022-08-10 21:50:00 +02:00
60c6b1c4bb Improve imports 2022-07-01 20:06:55 +02:00
8a8ea7ec09 Make use of more SQLAlchemy Utils 2022-07-01 17:17:52 +02:00
b7e627a216 Add first couple of ORM models; implement some helper functions 2022-07-01 16:01:07 +02:00
71f9db6c0d Add ORM base class; make Postgresql mandatory; add dependencies 2022-07-01 10:46:48 +02:00
eb08e5a674 Add database setup 2022-06-29 13:06:20 +02:00
8ec4e8885a Add uvicorn server; make in-memory DB default 2022-06-29 13:04:36 +02:00
27696ad57c Implement settings singleton 2022-06-29 12:43:48 +02:00
aa7fed7817 Add meta data and dependencies 2022-06-29 11:53:42 +02:00
27 changed files with 1104 additions and 7 deletions

View File

@ -12,7 +12,7 @@ Company publications API
## Dependencies ## Dependencies
Python Version ..., OS ... Python Version 3.10+, OS ...
## Building from source ## Building from source

View File

@ -0,0 +1,8 @@
Pydantic
FastAPI
SQLAlchemy[asyncio]==1.4.35
Alembic
SQLAlchemy-Utils
SQLModel
Babel
Python-Slugify

View File

@ -1,2 +1,4 @@
-r common.txt -r srv.txt
coverage coverage
sqlalchemy-stubs
aiosqlite

2
requirements/srv.txt Normal file
View File

@ -0,0 +1,2 @@
-r common.txt
Uvicorn[standard]

View File

@ -1,8 +1,8 @@
[metadata] [metadata]
name = compub name = compub
version = 0.0.1 version = 0.0.1
author = Daniil author = Daniil Fajnberg
author_email = mail@placeholder123.to author_email = mail@daniil.fajnberg.de
description = Company publications API description = Company publications API
long_description = file: README.md long_description = file: README.md
long_description_content_type = text/markdown long_description_content_type = text/markdown
@ -10,20 +10,36 @@ url = https://git.fajnberg.de/daniil/compub
project_urls = project_urls =
Bug Tracker = https://git.fajnberg.de/daniil/compub/issues Bug Tracker = https://git.fajnberg.de/daniil/compub/issues
classifiers = classifiers =
Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.10
Operating System :: OS Independent Operating System :: OS Independent
Development Status :: 3 - Alpha
Framework :: AsyncIO
Framework :: FastAPI
[options] [options]
package_dir = package_dir =
= src = src
packages = find: packages = find:
python_requires = >=3 python_requires = >=3.10, <4
install_requires = install_requires =
... Pydantic
FastAPI
SQLAlchemy[asyncio]==1.4.35
Alembic
SQLAlchemy-Utils
SQLModel
Babel
Python-Slugify
[options.extras_require] [options.extras_require]
srv =
Uvicorn[standard]
dev = dev =
Uvicorn[standard]
coverage coverage
sqlalchemy-stubs
aiosqlite
[options.packages.find] [options.packages.find]
where = src where = src

30
src/compub/__main__.py Normal file
View File

@ -0,0 +1,30 @@
from argparse import ArgumentParser, SUPPRESS
from pathlib import Path
from typing import Any, Sequence
import uvicorn
from .settings import PROGRAM_NAME, CONFIG_FILE_PATH_PARAM, DEFAULT_CONFIG_FILE_PATHS, init, settings
def parse_cli(args: Sequence[str] = None) -> dict[str, Any]:
parser = ArgumentParser(PROGRAM_NAME)
parser.add_argument(
'-c', f'--{CONFIG_FILE_PATH_PARAM}',
type=Path,
metavar='PATH',
default=SUPPRESS,
help=f"Paths to config file that will take precedence over all others; "
f"the following {len(DEFAULT_CONFIG_FILE_PATHS)} paths are always checked first (in that order):"
f" {','.join(str(p) for p in DEFAULT_CONFIG_FILE_PATHS)}"
)
return vars(parser.parse_args(args))
def main():
init(**parse_cli())
uvicorn.run(f'{PROGRAM_NAME}.routes:app', **settings.server.dict())
if __name__ == '__main__':
main()

View File

@ -0,0 +1 @@
from .geography import *

View File

@ -0,0 +1,33 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.expression import select
from compub.models.geography import *
__all__ = [
# 'get_state_by_name_and_country',
'get_states',
'create_state'
]
# async def get_state_by_name_and_country(session: AsyncSession, name: str, country: str) -> db.StateProvince:
# statement = select(db.StateProvince).filter(
# db.StateProvince.name == name,
# db.StateProvince.country == country
# ).limit(1)
# result = await session.execute(statement)
# return result.scalars().first()
async def get_states(session: AsyncSession, skip: int = 0, limit: int = 100) -> list[StateProvince]:
statement = select(StateProvince).offset(skip).limit(limit)
result = await session.execute(statement)
return result.scalars().all()
async def create_state(session: AsyncSession, state: StateProvince) -> StateProvince:
session.add(state)
await session.commit()
await session.refresh(state)
return state

6
src/compub/exceptions.py Normal file
View File

@ -0,0 +1,6 @@
class ConfigError(Exception):
pass
class NoDatabaseConfigured(ConfigError):
pass

View File

@ -0,0 +1,3 @@
from .base import DB
from .geography import *
from .companies import *

98
src/compub/models/base.py Normal file
View File

@ -0,0 +1,98 @@
from datetime import datetime
from typing import Any, Iterator, Optional
from sqlalchemy.ext.asyncio.engine import AsyncEngine, create_async_engine
from sqlalchemy.ext.asyncio.session import AsyncSession
from sqlalchemy.future.engine import Engine
from sqlalchemy.orm.session import sessionmaker
from sqlalchemy.sql.functions import now as db_now
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import TIMESTAMP
from sqlalchemy_utils.functions.orm import get_columns
from sqlalchemy_utils.listeners import force_auto_coercion
from sqlalchemy_utils.types.choice import Choice
from sqlmodel.main import Field, SQLModel
from compub.exceptions import NoDatabaseConfigured
from compub.settings import settings
from sqlmodel import create_engine, Session
DEFAULT_PK_TYPE = int
class DB:
def __init__(self):
self._engine: AsyncEngine | Engine | None = None
self.session_maker: sessionmaker | None = None
def start_engine(self) -> None:
if settings.db_uri is None:
raise NoDatabaseConfigured
if settings.db_uri.scheme == 'sqlite':
self._engine = create_engine(settings.db_uri, echo=True)
self.session_maker = sessionmaker(self.engine, expire_on_commit=False, class_=Session)
else:
self._engine = create_async_engine(settings.db_uri, future=True)
self.session_maker = sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
force_auto_coercion()
@property
def engine(self) -> Engine:
if self._engine is None:
self.start_engine()
assert isinstance(self._engine, (AsyncEngine, Engine))
return self._engine
async def get_session(self) -> AsyncSession:
if self.session_maker is None:
self.start_engine()
assert isinstance(self.session_maker.class_, AsyncSession)
session = self.session_maker()
try:
yield session
finally:
await session.close()
def get_session_blocking(self) -> Session:
if self.session_maker is None:
self.start_engine()
assert isinstance(self.session_maker.class_, Session)
session = self.session_maker()
try:
yield session
finally:
session.close()
class AbstractBase(SQLModel):
id: Optional[DEFAULT_PK_TYPE] = Field(default=None, primary_key=True)
date_created: Optional[datetime] = Field(
default=None, sa_column=Column(TIMESTAMP(timezone=False), server_default=db_now())
)
date_updated: Optional[datetime] = Field(
default=None, sa_column=Column(TIMESTAMP(timezone=False), server_default=db_now(), onupdate=db_now())
)
def __repr__(self) -> str:
fields = self.iter_fields(excl_non_repr=True, excl_null_value=True)
attrs = ', '.join(f"{name}={repr(value)}" for name, value in fields)
return f"{self.__class__.__name__}({attrs})"
def iter_fields(self, excl_non_repr: bool = False, excl_null_value: bool = False) -> Iterator[tuple[str, Any]]:
for name in get_columns(self).keys():
if excl_non_repr and name in self.get_non_repr_fields():
continue
value = getattr(self, name)
if excl_null_value and value is None:
continue
yield name, value
@staticmethod
def get_non_repr_fields() -> list[str]:
return ['id', 'date_created', 'date_updated']
def get_choice_value(obj: Choice | str) -> str:
return obj.value if isinstance(obj, Choice) else obj

View File

@ -0,0 +1,226 @@
from datetime import date
from typing import Optional, TYPE_CHECKING
from slugify import slugify
from sqlalchemy.engine.base import Connection
from sqlalchemy.event.api import listens_for
from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.sql.expression import select
from sqlalchemy.sql.functions import count
from sqlalchemy.sql.schema import Column, Index
from sqlalchemy.sql.sqltypes import Unicode
from sqlalchemy_utils.types import CountryType
from sqlmodel.main import Field, Relationship
from compub.utils import multi_max
from .base import AbstractBase, DEFAULT_PK_TYPE as PK
from .geography import Address
if TYPE_CHECKING:
from .courts import RegisterNumber
__all__ = [
'LegalForm',
'LegalFormSubcategory',
'Industry',
'Executive',
'Company',
'CompanyName',
]
class LegalForm(AbstractBase, table=True):
__tablename__ = 'legal_form'
# Fields
short: str = Field(max_length=32, nullable=False, index=True)
name: Optional[str] = Field(default=None, max_length=255, sa_column=Column(Unicode(255)))
country: Optional[str] = Field(sa_column=Column(CountryType))
# Relationships
subcategories: list['LegalFormSubcategory'] = Relationship(
back_populates='legal_form', sa_relationship_kwargs={'lazy': 'selectin'}
)
def __str__(self) -> str:
return str(self.short)
class LegalFormSubcategory(AbstractBase, table=True):
__tablename__ = 'legal_form_subcategory'
__table_args__ = (
Index('ux_legal_form_subcategory', 'short', 'legal_form_id', unique=True),
)
# Fields
short: str = Field(max_length=32, nullable=False, index=True)
name: Optional[str] = Field(default=None, max_length=255, sa_column=Column(Unicode(255)))
# Relationships
legal_form_id: Optional[PK] = Field(
foreign_key='legal_form.id', default=None, nullable=False, index=True
)
legal_form: Optional[LegalForm] = Relationship(
back_populates='subcategories', sa_relationship_kwargs={'lazy': 'selectin'}
)
companies: list['Company'] = Relationship(
back_populates='legal_form', sa_relationship_kwargs={'lazy': 'selectin'}
)
def __str__(self) -> str:
return str(self.short)
class CompanyIndustryLink(AbstractBase, table=True):
__tablename__ = 'company_industry'
__table_args__ = (
Index('ux_company_industry', 'company_id', 'industry_id', unique=True),
)
# Relationships
company_id: Optional[PK] = Field(foreign_key='company.id', default=None, nullable=False, primary_key=True)
industry_id: Optional[PK] = Field(foreign_key='industry.id', default=None, nullable=False, primary_key=True)
class CompanyExecutiveLink(AbstractBase, table=True):
__tablename__ = 'company_executive'
__table_args__ = (
Index('ux_company_executive', 'company_id', 'executive_id', unique=True),
)
# Relationships
company_id: Optional[PK] = Field(foreign_key='company.id', default=None, nullable=False, primary_key=True)
executive_id: Optional[PK] = Field(foreign_key='executive.id', default=None, nullable=False, primary_key=True)
class Industry(AbstractBase, table=True):
__tablename__ = 'industry'
# Fields
name: str = Field(max_length=255, nullable=False, index=True, sa_column_kwargs={'unique': True})
# Relationships
companies: list['Company'] = Relationship(
back_populates='industries', link_model=CompanyIndustryLink, sa_relationship_kwargs={'lazy': 'selectin'}
)
def __str__(self) -> str:
return str(self.name)
class Executive(AbstractBase, table=True):
__tablename__ = 'executive'
__MAX_LENGTH_NAME__: int = 255
# Fields
name: str = Field(
max_length=__MAX_LENGTH_NAME__, sa_column=Column(Unicode(__MAX_LENGTH_NAME__), nullable=False, index=True)
)
# Relationships
companies: list['Company'] = Relationship(
back_populates='executives', link_model=CompanyExecutiveLink, sa_relationship_kwargs={'lazy': 'selectin'}
)
def __str__(self) -> str:
return str(self.name)
class Company(AbstractBase, table=True):
__tablename__ = 'company'
# Fields
visible: bool = Field(default=True, nullable=False, index=True)
insolvent: bool = Field(default=False, nullable=False, index=True)
founding_data: Optional[date]
liquidation_date: Optional[date]
# TODO: Get rid of city; implement address properly
city: Optional[str] = Field(max_length=255, nullable=True, index=True)
# Relationships
legal_form_id: Optional[PK] = Field(
foreign_key='legal_form_subcategory.id', default=None, index=True
)
legal_form: Optional[LegalFormSubcategory] = Relationship(
back_populates='companies', sa_relationship_kwargs={'lazy': 'selectin'}
)
address_id: Optional[PK] = Field(
foreign_key='address.id', default=None, index=True
)
address: Optional[Address] = Relationship(
back_populates='companies', sa_relationship_kwargs={'lazy': 'selectin'}
)
industries: list[Industry] = Relationship(
back_populates='companies', link_model=CompanyIndustryLink, sa_relationship_kwargs={'lazy': 'selectin'}
)
executives: list[Executive] = Relationship(
back_populates='companies', link_model=CompanyExecutiveLink, sa_relationship_kwargs={'lazy': 'selectin'}
)
names: list['CompanyName'] = Relationship(
back_populates='company', sa_relationship_kwargs={'lazy': 'selectin'}
)
register_numbers: list['RegisterNumber'] = Relationship(
back_populates='company', sa_relationship_kwargs={'lazy': 'selectin'}
)
def __str__(self) -> str:
return str(self.current_name or f"<Company {self.id}>")
@property
def current_name(self) -> Optional['CompanyName']:
return multi_max(list(self.names), CompanyName.get_reg_date, 'date_updated', default=None)
class CompanyName(AbstractBase, table=True):
__tablename__ = 'company_name'
__table_args__ = (
Index('ux_company_name_company_id', 'name', 'company_id', unique=True),
)
__MAX_LENGTH_NAME__: int = 768
__MAX_SLUG_LENGTH__: int = 255
# Fields
name: str = Field(
max_length=__MAX_LENGTH_NAME__, sa_column=Column(Unicode(__MAX_LENGTH_NAME__), nullable=False, index=True)
)
date_registered: Optional[date]
slug: Optional[str] = Field(default=None, max_length=__MAX_SLUG_LENGTH__, index=True)
# Relationships
company_id: Optional[PK] = Field(
foreign_key='company.id', default=None, nullable=False, index=True
)
company: Optional[Company] = Relationship(
back_populates='names', sa_relationship_kwargs={'lazy': 'selectin'}
)
def __str__(self) -> str:
return str(self.name)
def get_reg_date(self) -> date:
return date(1, 1, 1) if self.date_registered is None else self.date_registered
@property
def max_slug_length(self) -> int:
return self.__MAX_SLUG_LENGTH__
@listens_for(CompanyName, 'before_insert')
def generate_company_name_slug(_mapper: Mapper, connection: Connection, target: CompanyName) -> None:
if target.slug:
return
slug = slugify(target.name)[:(target.max_slug_length - 2)]
statement = select(count()).select_from(CompanyName).where(CompanyName.slug.startswith(slug))
num = connection.execute(statement).scalar()
if num == 0:
target.slug = slug
else:
target.slug = f'{slug}-{str(num + 1)}'

234
src/compub/models/courts.py Normal file
View File

@ -0,0 +1,234 @@
from enum import Enum as EnumPy
from typing import Optional
from sqlalchemy.sql.schema import Column, Index
from sqlalchemy.sql.sqltypes import Unicode, Enum as EnumSQL
from sqlmodel.main import Field, Relationship
from .base import AbstractBase, DEFAULT_PK_TYPE as PK
from .companies import Company
from .geography import StateProvince, Address
__all__ = [
'CourtClass',
'CourtDepartment',
'Court',
'RegisterBranch',
'RegisterNumber',
]
# Amtsgericht -> Landgericht -> OLG -> BGH
class CourtClass(AbstractBase, table=True):
__tablename__ = 'court_class'
# Fields
short: str = Field(
max_length=32,
nullable=False,
index=True
)
name: str = Field(
max_length=255,
sa_column=Column(Unicode(255))
)
# Relationships
courts: list['Court'] = Relationship(
back_populates='court_class',
sa_relationship_kwargs={'lazy': 'selectin'}
)
def __str__(self) -> str:
return str(self.name)
class CourtDepartmentCourtLink(AbstractBase, table=True):
__tablename__ = 'court_department_court'
__table_args__ = (
Index('ux_court_department_court', 'court_department_id', 'court_id', unique=True),
)
# Relationships
court_department_id: Optional[PK] = Field(
foreign_key='court_department.id',
default=None,
nullable=False,
primary_key=True
)
court_id: Optional[PK] = Field(
foreign_key='court.id',
default=None,
nullable=False,
primary_key=True
)
# Abteilung innerhalb eines Amtsgerichts (z. B. Registergericht, Insolvenzgericht etc.)
class CourtDepartment(AbstractBase, table=True):
__tablename__ = 'court_department'
__REG_COURT_PK__ = 1
__INS_COURT_PK__ = 2
# Fields
name: str = Field(
max_length=255,
sa_column=Column(Unicode(255))
)
# Relationships
courts: list['Court'] = Relationship(
back_populates='court_departments',
link_model=CourtDepartmentCourtLink,
sa_relationship_kwargs={'lazy': 'selectin'}
)
def __str__(self) -> str:
return str(self.name)
class Court(AbstractBase, table=True):
__tablename__ = 'court'
# Fields
name: str = Field(
max_length=255,
sa_column=Column(Unicode(255))
)
# Relationships
court_class_id: Optional[PK] = Field(
foreign_key='court_class.id',
default=None,
nullable=False,
index=True
)
court_class: Optional[CourtClass] = Relationship(
back_populates='courts',
sa_relationship_kwargs={'lazy': 'selectin'}
)
state_province_id: Optional[PK] = Field(
foreign_key='state_province.id',
default=None,
nullable=True,
index=True
)
state_province: Optional[StateProvince] = Relationship(
back_populates='courts',
sa_relationship_kwargs={'lazy': 'selectin'}
)
address_id: Optional[PK] = Field(
foreign_key='address.id',
default=None,
nullable=True,
index=True
)
address: Optional[Address] = Relationship(
back_populates='courts',
sa_relationship_kwargs={'lazy': 'selectin'}
)
parent_court_id: Optional[PK] = Field(
foreign_key='court.id',
default=None,
nullable=True,
index=True
)
parent_court: Optional['Court'] = Relationship(
back_populates='sub_courts',
sa_relationship_kwargs=dict(
lazy='selectin',
remote_side='Court.id'
)
)
sub_courts: list['Court'] = Relationship(
back_populates='parent_court',
sa_relationship_kwargs={'lazy': 'selectin'}
)
court_departments: list[CourtDepartment] = Relationship(
back_populates='courts',
link_model=CourtDepartmentCourtLink,
sa_relationship_kwargs={'lazy': 'selectin'}
)
register_numbers: list['RegisterNumber'] = Relationship(
back_populates='court', sa_relationship_kwargs={'lazy': 'selectin'}
)
def __str__(self) -> str:
return str(self.name)
class RegisterBranch(str, EnumPy):
HRA = 'HRA'
HRB = 'HRB'
GnR = 'GnR'
PR = 'PR'
VR = 'VR'
class RegisterNumber(AbstractBase, table=True):
__tablename__ = 'register_number'
# Fields
branch: Optional[RegisterBranch] = Field(
default=None,
sa_column=Column(
EnumSQL(RegisterBranch),
nullable=True,
index=True
)
)
number: str = Field(
max_length=255,
index=True
)
suffix: Optional[str] = Field(
max_length=4,
nullable=True
)
ureg_company_id: Optional[int] = Field(
nullable=True,
index=True
)
# Relationships
court_id: Optional[PK] = Field(
foreign_key='court.id',
default=None,
index=True
)
court: Optional[Court] = Relationship(
back_populates='register_numbers',
sa_relationship_kwargs=dict(
lazy='selectin'
)
)
company_id: Optional[PK] = Field(
foreign_key='company.id',
default=None,
index=True
)
company: Optional[Company] = Relationship(
back_populates='register_numbers',
sa_relationship_kwargs=dict(
lazy='selectin'
)
)
def __str__(self) -> str:
return f'{self.with_branch_code} ({self.company.current_name})'
@property
def with_branch_code(self) -> str:
return f'{self.branch} {self.number}' if self.branch else str(self.number)
@property
def verbose_id(self) -> str:
return f'{self.court} {self.with_branch_code}'

View File

@ -0,0 +1,116 @@
from typing import Any, Optional, TYPE_CHECKING
from pydantic import validator
from sqlalchemy.sql.schema import Column, Index
from sqlalchemy.sql.sqltypes import Unicode
from sqlalchemy_utils.primitives.country import Country
from sqlalchemy_utils.types import CountryType
from sqlmodel.main import Field, Relationship
from .base import AbstractBase, DEFAULT_PK_TYPE as PK
if TYPE_CHECKING:
from .companies import Company
from .courts import Court
__all__ = [
'StateProvince',
'City',
'Street',
'Address',
]
class StateProvince(AbstractBase, table=True):
__tablename__ = 'state_province'
__table_args__ = (
Index('ux_state_province_name_country', 'name', 'country', unique=True),
)
# Fields
country: str = Field(sa_column=Column(CountryType, nullable=False, index=True))
name: str = Field(max_length=255, sa_column=Column(Unicode(255), nullable=False, index=True))
# Relationships
cities: list['City'] = Relationship(
back_populates='state_province', sa_relationship_kwargs={'lazy': 'selectin'}
)
courts: list['Court'] = Relationship(
back_populates='state_province', sa_relationship_kwargs={'lazy': 'selectin'}
)
@validator('country', pre=True)
def country_as_uppercase_string(cls, v: Any) -> str:
if isinstance(v, Country):
return v.code
if isinstance(v, str):
return v.upper()
raise TypeError
class City(AbstractBase, table=True):
__tablename__ = 'city'
__table_args__ = (
Index('ux_city_name_zip_state', 'name', 'zip_code', 'state_province_id', unique=True),
)
# Fields
zip_code: str = Field(max_length=5, nullable=False, index=True)
name: str = Field(max_length=255, sa_column=Column(Unicode(255), nullable=False, index=True))
# Relationships
state_province_id: Optional[PK] = Field(
foreign_key='state_province.id', default=None, nullable=False, index=True
)
state_province: Optional[StateProvince] = Relationship(
back_populates='cities', sa_relationship_kwargs={'lazy': 'selectin'}
)
streets: list['Street'] = Relationship(
back_populates='city', sa_relationship_kwargs={'lazy': 'selectin'}
)
class Street(AbstractBase, table=True):
__tablename__ = 'street'
# Fields
name: str = Field(max_length=255, sa_column=Column(Unicode(255), nullable=False, index=True))
# Relationships
city_id: Optional[PK] = Field(
foreign_key='city.id', default=None, nullable=False, index=True
)
city: Optional[City] = Relationship(
back_populates='streets', sa_relationship_kwargs={'lazy': 'selectin'}
)
addresses: list['Address'] = Relationship(
back_populates='street', sa_relationship_kwargs={'lazy': 'selectin'}
)
class Address(AbstractBase, table=True):
__tablename__ = 'address'
# Fields
house_number: str = Field(max_length=8, nullable=False)
supplement: str = Field(max_length=255)
# Relationships
street_id: Optional[PK] = Field(
foreign_key='street.id', default=None, nullable=False, index=True
)
street: Optional[Street] = Relationship(
back_populates='addresses', sa_relationship_kwargs={'lazy': 'selectin'}
)
companies: list['Company'] = Relationship(
back_populates='address', sa_relationship_kwargs={'lazy': 'selectin'}
)
courts: list['Court'] = Relationship(
back_populates='address', sa_relationship_kwargs={'lazy': 'selectin'}
)

43
src/compub/routes.py Normal file
View File

@ -0,0 +1,43 @@
from fastapi import FastAPI, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel
from .models import *
from .models.base import DB
from . import crud
api, db = FastAPI(), DB()
@api.on_event('startup')
async def initialize_db():
async with db.engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
await conn.run_sync(SQLModel.metadata.create_all)
@api.on_event("shutdown")
async def close_connection_pool():
await db.engine.dispose()
@api.post("/states/", response_model=StateProvince)
async def create_state(state: StateProvince, session: AsyncSession = Depends(db.get_session)):
# db_obj = await crud.get_state_by_name_and_country(session, name=state.name, country=state.country)
# if db_obj:
# raise HTTPException(status_code=400, detail="Name already registered")
return await crud.create_state(session, state=state)
@api.get("/states/", response_model=list[StateProvince])
async def list_states(skip: int = 0, limit: int = 100, session: AsyncSession = Depends(db.get_session)):
return await crud.get_states(session, skip=skip, limit=limit)
# @app.post("/cities/", response_model=City)
# async def create_city(city: City, session: AsyncSession = Depends(get_session)):
# db_obj = await crud.get_state_by_name_and_country(session, name=state.name, country=state.country)
# if db_obj:
# raise HTTPException(status_code=400, detail="Name already registered")
# return await crud.create_state(session, state=state)

113
src/compub/settings.py Normal file
View File

@ -0,0 +1,113 @@
import logging
import logging.config
from pathlib import Path
from typing import Any, Callable, ClassVar
from pydantic import BaseModel, BaseSettings, AnyUrl, validator
from pydantic.env_settings import SettingsSourceCallable
from yaml import safe_load
log = logging.getLogger(__name__)
PROGRAM_NAME = 'compub'
THIS_DIR = Path(__file__).parent
PROJECT_DIR = THIS_DIR.parent.parent
DEFAULT_CONFIG_FILE_NAME = 'config.yaml'
DEFAULT_CONFIG_FILE_PATHS = [
Path('/etc', PROGRAM_NAME, DEFAULT_CONFIG_FILE_NAME), # system directory
Path(PROJECT_DIR, DEFAULT_CONFIG_FILE_NAME), # project directory
Path('.', DEFAULT_CONFIG_FILE_NAME), # working directory
]
CONFIG_FILE_PATH_PARAM = 'config_file'
class AbstractBaseSettings(BaseSettings):
_config_file_paths: ClassVar[list[Path]] = DEFAULT_CONFIG_FILE_PATHS
def __init__(self, *args, **kwargs):
config_file_path = kwargs.pop(CONFIG_FILE_PATH_PARAM, None)
if config_file_path is not None:
self._config_file_paths.append(Path(config_file_path))
super().__init__(*args, **kwargs)
def get_config_file_paths(self) -> list[Path]:
return self._config_file_paths
class Config:
allow_mutation = False
env_file_encoding = 'utf-8'
underscore_attrs_are_private = True
@classmethod
def customise_sources(
cls,
init_settings: SettingsSourceCallable,
env_settings: SettingsSourceCallable,
file_secret_settings: SettingsSourceCallable
) -> tuple[Callable, ...]:
return init_settings, env_settings, _yaml_config_settings_source
def _yaml_config_settings_source(settings_obj: AbstractBaseSettings) -> dict[str, Any]:
"""
Incrementally loads (and updates) settings from all config files that can be found as returned by the
`Settings.get_config_file_paths` method and returns the result in a dictionary.
This function is intended to be used as a settings source in the `Config.customise_sources` method.
"""
config = {}
for path in settings_obj.get_config_file_paths():
if not path.is_file():
log.debug(f"No config file found at '{path}'")
continue
log.info(f"Reading config file '{path}'")
with open(path, 'r') as f:
config.update(safe_load(f))
return config
class ServerSettings(BaseModel):
host: str = '127.0.0.1'
port: int = 9009
uds: str | None = None
class DBURI(AnyUrl):
host_required = False
class Settings(AbstractBaseSettings):
db_uri: DBURI | None = None
server: ServerSettings = ServerSettings()
log_config: dict | Path | None = None
@validator('log_config')
def configure_logging(cls, v: dict | Path | None) -> dict | None:
if v is None:
return None
if isinstance(v, Path):
with open(v, 'r') as f:
logging_conf = safe_load(f)
logging.config.dictConfig(logging_conf)
return logging_conf
if isinstance(v, dict):
logging.config.dictConfig(v)
return v
raise TypeError
settings = Settings()
def init(**kwargs) -> None:
settings.__init__(**kwargs)
def update(**kwargs) -> None:
settings_dict = settings.dict()
settings_dict.update(kwargs)
settings.__init__(**settings_dict)

66
src/compub/utils.py Normal file
View File

@ -0,0 +1,66 @@
from operator import attrgetter
from typing import Any, Callable, TypeVar
T = TypeVar('T')
KeyFuncT = Callable[[T], Any]
_sentinel = object()
def multi_sort(obj_list: list[T], *parameters: str | KeyFuncT | tuple[str | KeyFuncT, bool]) -> None:
for param in reversed(parameters):
if isinstance(param, str):
obj_list.sort(key=attrgetter(param))
elif callable(param):
obj_list.sort(key=param)
else:
try:
param, reverse = param
assert isinstance(reverse, bool)
except (ValueError, TypeError):
raise ValueError(f"Sorting parameter {param} is neither a key nor a key-boolean-tuple.")
if isinstance(param, str):
obj_list.sort(key=attrgetter(param), reverse=reverse)
elif callable(param):
obj_list.sort(key=param, reverse=reverse)
else:
raise ValueError(f"Sorting key {param} is neither a string nor a callable.")
def multi_gt(left: T, right: T, *parameters: str | KeyFuncT | tuple[str | KeyFuncT, bool]) -> bool:
for param in parameters:
invert = False
if isinstance(param, str):
left_val, right_val = getattr(left, param), getattr(right, param)
elif callable(param):
left_val, right_val = param(left), param(right)
else:
try:
param, invert = param
assert isinstance(invert, bool)
except (ValueError, TypeError, AssertionError):
raise ValueError(f"Ordering parameter {param} is neither a key nor a key-boolean-tuple.")
if isinstance(param, str):
left_val, right_val = getattr(left, param), getattr(right, param)
elif callable(param):
left_val, right_val = param(left), param(right)
else:
raise ValueError(f"Ordering key {param} is neither a string nor a callable.")
if left_val == right_val:
continue
return left_val < right_val if invert else left_val > right_val
return False
def multi_max(obj_list: list[T], *parameters: str | KeyFuncT | tuple[str | KeyFuncT, bool],
default: Any = _sentinel) -> T:
try:
largest = obj_list[0]
except IndexError:
if default is not _sentinel:
return default
raise ValueError("Cannot get largest item from an empty list.")
for obj in obj_list[1:]:
if multi_gt(obj, largest, *parameters):
largest = obj
return largest

5
tests/fixtures/companies/company.json vendored Normal file
View File

@ -0,0 +1,5 @@
[
{
}
]

View File

View File

@ -0,0 +1,9 @@
[
{
"id": 1,
"name": "Energy"
}, {
"id": 2,
"name": "Retail"
}
]

View File

@ -0,0 +1,16 @@
[
{
"id": 1,
"short": "KG",
"name": "Kommanditgesellschaft",
"country": "DE"
}, {
"id": 2,
"short": "AG",
"name": "Aktiengesellschaft",
"country": "DE"
}, {
"id": 123,
"short": "Ausl.HRB"
}
]

View File

@ -0,0 +1,17 @@
[
{
"id": 111,
"short": "GmbH & Co. KG",
"name": "Gesellschaft mit beschränkter Haftung & Compagnie Kommanditgesellschaft",
"legal_form_id": 1
}, {
"id": 112,
"short": "AG & Co. KG",
"name": "Aktiengesellschaft & Compagnie Kommanditgesellschaft",
"legal_form_id": 1
}, {
"id": 69420,
"short": "LLC",
"legal_form_id": 123
}
]

8
tests/fixtures/geography/address.json vendored Normal file
View File

@ -0,0 +1,8 @@
[
{
"id": 9999999,
"house_number": "123AB",
"supplement": "whatever",
"street_id": 1
}
]

23
tests/fixtures/geography/city.json vendored Normal file
View File

@ -0,0 +1,23 @@
[
{
"id": 1122,
"zip_code": "10405",
"name": "Berlin",
"state_province_id": 100
}, {
"id": 9,
"zip_code": "33299",
"name": "Miami",
"state_province_id": 30
}, {
"id": 10,
"zip_code": "33694",
"name": "Tampa",
"state_province_id": 30
}, {
"id": 78787878,
"zip_code": "91000",
"name": "Luhansk",
"state_province_id": 4531354
}
]

View File

@ -0,0 +1,15 @@
[
{
"id": 100,
"country": "DE",
"name": "Berlin"
}, {
"id": 30,
"country": "US",
"name": "Florida"
}, {
"id": 4531354,
"country": "UA",
"name": "Luhansk Oblast"
}
]

7
tests/fixtures/geography/street.json vendored Normal file
View File

@ -0,0 +1,7 @@
[
{
"id": 1,
"name": "Some Street",
"city_id": 1122
}
]