Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Feature/fix infra spatialite #79

Merged
merged 35 commits into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
fb8da82
Checks for correct signature used by the implementation class.
codecakes Aug 12, 2024
dbf8df1
Implements signatures for functional and decoupled GeolocationQuerySe…
codecakes Aug 12, 2024
bfa02f9
adds make test
codecakes Aug 12, 2024
d5ff73a
Merge branch 'main' into feature-location-impl
codecakes Aug 17, 2024
a55413c
ensure signature type checks for interface implementations
codecakes Aug 21, 2024
e288445
refactoring project structure to match a more decoupled logic away fr…
codecakes Aug 21, 2024
d1c733f
added todos command to check all TODOs in project. updated repository…
codecakes Aug 25, 2024
ad6805e
refactored code to adhere to interface requirements and implement mis…
codecakes Aug 25, 2024
b296d89
Merge branch 'main' into feature-location-impl
codecakes Aug 25, 2024
e99d741
update libraries for adding sqlite orm support
codecakes Aug 30, 2024
59fbde7
Merge branch 'main' into feature-location-impl
codecakes Aug 30, 2024
4f24bd9
added support libraries
codecakes Sep 2, 2024
ef85176
implements configure_database_session(services, settings) and on_start
codecakes Sep 2, 2024
3d3ff7c
Merge branch 'main' into feature/sqlite-repo
codecakes Sep 2, 2024
962a1da
Merge branch 'main' into feature/sqlite-repo
codecakes Sep 3, 2024
916defe
refactored unit tests to have dummy functions move to conftest. fix t…
codecakes Sep 7, 2024
22e14e3
refactors dummy tests, removed to conftest. make test works. added te…
codecakes Sep 9, 2024
00d5884
Merge branch 'main' into feature/sqlite-repo
codecakes Sep 9, 2024
90cb943
fix typo
codecakes Sep 9, 2024
a180a72
updated how tests are run to include make test which sets necessary e…
codecakes Sep 9, 2024
a7d58c9
Merge remote-tracking branch 'ssh' into feature/sqlite-repo
codecakes Sep 9, 2024
5d79824
added changes to support api and integration tests
codecakes Sep 9, 2024
c576c43
added sqlite models to support sqlite database. database.py enhances …
codecakes Sep 9, 2024
fca13b0
removed obsolete trigger functions
codecakes Sep 9, 2024
c8f23f9
major rewrite of test services for integration test for Sqlite Repo D…
codecakes Sep 9, 2024
39bbd27
Merge branch 'main' into feature/sqlite-repo
codecakes Sep 9, 2024
5ddf9ad
refactored into setUpTestDatabase class for DRY
codecakes Sep 11, 2024
3d15a1f
WIP test_fetch_facilities integration test
codecakes Sep 11, 2024
87265fd
Feature/sqlite repo: setup for GeoLocationServiceSqlRepoDBTest (#68)
codecakes Sep 9, 2024
fbb2c0a
Merge branch 'main' into feature/sqlite-repo
codecakes Sep 11, 2024
0dd37fb
added docstrings, TODO explainer and specific exception
codecakes Sep 13, 2024
dd7ab65
Merge branch 'main' into feature/sqlite-repo
codecakes Sep 20, 2024
f27535b
these changes reflect a working spatialite extension to sqlite. the t…
codecakes Sep 22, 2024
f3434d8
Update xcov19/infra/models.py
codecakes Sep 22, 2024
c4d1c26
reduce code complexity. fix uuid generation. add doctstring descripti…
codecakes Sep 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
# Reuse the stage from Dockerfile.build
FROM xcov19-setup AS run

# Set the working directory
WORKDIR /app

# Bust cached build if --build CACHEBUST=<some data> is passed
# to ensure updated source code is built
ARG CACHEBUST
COPY --chown=nonroot:nonroot --chmod=555 xcov19 xcov19/
COPY --chown=nonroot:nonroot --chmod=555 Makefile .
COPY --chown=nonroot:nonroot --chmod=555 *.sh .

USER nonroot:nonroot

# Set the start command
Expand Down
13 changes: 5 additions & 8 deletions Dockerfile.build
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,6 @@ RUN chown -R nonroot:nonroot /app
RUN mkdir -p /var/cache
RUN chown -R nonroot:nonroot /var/cache

# Copy the application code
COPY --chown=nonroot:nonroot --chmod=555 xcov19 xcov19/
COPY --chown=nonroot:nonroot --chmod=555 Makefile .
COPY --chown=nonroot:nonroot --chmod=555 pyproject.toml .
COPY --chown=nonroot:nonroot --chmod=555 poetry.lock .
COPY --chown=nonroot:nonroot --chmod=555 *.sh .
COPY --chown=nonroot:nonroot --chmod=555 LICENSE .

ENV POETRY_NO_INTERACTION=1
ENV POETRY_VIRTUALENVS_CREATE=false
ENV POETRY_CACHE_DIR='/var/cache/pypoetry'
Expand All @@ -107,6 +99,11 @@ RUN curl --proto "=https" --tlsv1.2 -sSf -L https://install.python-poetry.org |
RUN mkdir -p /var/cache/pypoetry && chown -R nonroot:nonroot /var/cache/pypoetry
RUN chown -R nonroot:nonroot /usr/local/ && chmod -R 755 /usr/local/

# Copy the application code
COPY --chown=nonroot:nonroot --chmod=555 pyproject.toml .
COPY --chown=nonroot:nonroot --chmod=555 poetry.lock .
COPY --chown=nonroot:nonroot --chmod=555 LICENSE .

# Switch to nonroot user
USER nonroot:nonroot

Expand Down
10 changes: 10 additions & 0 deletions Dockerfile.test-integration
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
# Reuse the stage from Dockerfile.build
FROM xcov19-setup AS test-integration

# Set the working directory
WORKDIR /app

# Bust cached build if --build CACHEBUST=<some data> is passed
# to ensure updated source code is built
ARG CACHEBUST=1
COPY --chown=nonroot:nonroot --chmod=555 xcov19 xcov19/
COPY --chown=nonroot:nonroot --chmod=555 Makefile .
COPY --chown=nonroot:nonroot --chmod=555 *.sh .

# Switch to nonroot user
USER nonroot:nonroot

Expand Down
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,22 @@ test:
APP_ENV=test APP_DB_ENGINE_URL="sqlite+aiosqlite://" pytest -s xcov19/tests/ -m "not slow and not integration and not api"

test-integration:
APP_ENV=test APP_DB_ENGINE_URL="sqlite+aiosqlite://" pytest -s xcov19/tests/ -m "integration"
APP_ENV=test PYTHON_CONFIGURE_OPTS="--enable-loadable-sqlite-extensions" APP_DB_ENGINE_URL="sqlite+aiosqlite://" pytest -s xcov19/tests/ -m "integration"

todos:
@grep -rn "TODO:" xcov19/ --exclude-dir=node_modules --include="*.py"

set-docker:
@bash set_docker.sh

docker-build:
docker build --load -f Dockerfile.build -t $(XCOV19_SETUP_IMAGE) .

docker-integration:
docker build --load -f Dockerfile.test-integration -t $(XCOV19_TEST_INTEGRATION_SETUP_IMAGE) .

docker-run-server:
docker compose -f docker-compose.yml up --build
docker compose -f docker-compose.yml up --build --remove-orphans

docker-test-integration:
make docker-integration && docker run -it -f Dockerfile.test-integration
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ x-shared-config: &shared-config
max-file: 2

services:
xcov19-app:
app:
<<: *shared-config
build:
context: .
dockerfile: Dockerfile
dockerfile: Dockerfile
args:
CACHEBUST: ${CACHEBUST:-$(date +%s)}
569 changes: 299 additions & 270 deletions poetry.lock

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ authors = ["codecakes <akulmat@protonmail.com>"]
readme = "README.md"
package-mode = true


[tool.poetry.dependencies]
python = "^3.12"
pydantic = "^2.9.1"
Expand All @@ -20,6 +21,7 @@ alembic = "^1.13.2"
aiosqlite = "^0.20.0"
sqlmodel = {version="^0.0.22"}
rich = {version = "^13.8.0"}
spatialite = "^0.0.3"

ruff = { version = "^0.6.3", optional = true }
mypy = { version = "^1.11.2", optional = true }
Expand Down Expand Up @@ -66,8 +68,9 @@ include = [
]
exclude = [
"**/node_modules",
"**/__pycache__"]
venv = "xcov19-7M_0Y8Vx-py3.12"
"**/__pycache__"
]
# venv = "xcov19-7M_0Y8Vx-py3.12"
reportMissingImports = true

[[tool.pyright.executionEnvironments]]
Expand Down
9 changes: 8 additions & 1 deletion run.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
#!/bin/bash

if [ -f "xcov19.db" ]; then rm xcov19.db; fi; APP_ENV=dev APP_DB_ENGINE_URL="sqlite+aiosqlite:///xcov19.db" poetry run python3 -m xcov19.dev
echo "listing all files";
ls;
if [ -f "xcov19.db" ]; then
echo "removing database";
rm xcov19.db;
fi;

APP_ENV=dev APP_DB_ENGINE_URL="sqlite+aiosqlite:///xcov19.db" poetry run python3 -m xcov19.dev
42 changes: 42 additions & 0 deletions xcov19/app/database.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import sys
import aiosqlite
from rodi import Container
from xcov19.infra.models import SQLModel
from sqlmodel import text
Expand All @@ -11,9 +13,11 @@
AsyncEngine,
async_sessionmaker,
)
from sqlalchemy.dialects.sqlite.aiosqlite import AsyncAdapt_aiosqlite_connection

import logging
from sqlalchemy.pool import AsyncAdaptedQueuePool
from sqlalchemy import event

db_logger = logging.getLogger(__name__)
db_fmt = logging.Formatter(
Expand Down Expand Up @@ -45,11 +49,49 @@ def __call__(self) -> async_sessionmaker[AsyncSessionWrapper]:
)


async def _load_spatialite(dbapi_conn: AsyncAdapt_aiosqlite_connection) -> None:
"""Loads spatialite sqlite extension."""
conn: aiosqlite.Connection = dbapi_conn.driver_connection
await conn.enable_load_extension(True)
await conn.load_extension("mod_spatialite")
db_logger.info("======= PRAGMA load_extension successful =======")
try:
async with conn.execute("SELECT spatialite_version() as version") as cursor:
result = await cursor.fetchone()
db_logger.info(f"==== Spatialite Version: {result} ====")
db_logger.info("===== mod_spatialite loaded =====")
except (AttributeError, aiosqlite.OperationalError) as e:
db_logger.error(e)
raise (e)


def setup_spatialite(engine: AsyncEngine) -> None:
"""An event listener hook to setup spatialite using aiosqlite."""

@event.listens_for(engine.sync_engine, "connect")
def load_spatialite(
dbapi_conn: AsyncAdapt_aiosqlite_connection, _connection_record
):
loop = asyncio.get_running_loop()
# Schedule the coroutine in the existing event loop
loop.create_task(_load_spatialite(dbapi_conn))


async def setup_database(engine: AsyncEngine) -> None:
"""Sets up tables for database."""

setup_spatialite(engine)
async with engine.begin() as conn:
# Enable extension loading
await conn.execute(text("PRAGMA load_extension = 1"))
# db_logger.info("SQLAlchemy setup to load the SpatiaLite extension.")
# await conn.execute(text("SELECT load_extension('/opt/homebrew/Cellar/libspatialite/5.1.0_1/lib/mod_spatialite.dylib')"))
# await conn.execute(text("SELECT load_extension('mod_spatialite')"))
# see: https://sqlmodel.tiangolo.com/tutorial/relationship-attributes/cascade-delete-relationships/#enable-foreign-key-support-in-sqlite
await conn.execute(text("PRAGMA foreign_keys=ON"))
# test_result = await conn.execute(text("SELECT spatialite_version() as version;"))
# print(f"==== Spatialite Version: {test_result.fetchone()} ====")

await conn.run_sync(SQLModel.metadata.create_all)
await conn.commit()
db_logger.info("===== Database tables setup. =====")
Expand Down
3 changes: 3 additions & 0 deletions xcov19/domain/models/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class FacilityOwnership(enum.StrEnum):
type Specialties = List[str]
type Qualification = List[str]
type PracticeExpYears = int | float
type MoneyType = int | float


@dataclass
Expand Down Expand Up @@ -73,6 +74,7 @@ class Doctor:
specialties: Specialties
degree: Qualification
experience: PracticeExpYears
fee: MoneyType


@dataclass
Expand All @@ -84,5 +86,6 @@ class Provider:
facility_type: FacilityType
ownership: FacilityOwnerType
specialties: Specialties
available_doctors: List[Doctor]
stars: Annotated[int, Stars(min_rating=1, max_rating=5)]
reviews: Annotated[int, Reviews(value=0)]
104 changes: 86 additions & 18 deletions xcov19/infra/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,73 @@

from __future__ import annotations

from typing import List
import json
from typing import Annotated, Dict, List, Tuple, Any
from pydantic import GetCoreSchemaHandler, TypeAdapter
from pydantic_core import CoreSchema, core_schema
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.type_api import _BindProcessorType
from sqlmodel import SQLModel, Field, Relationship
from sqlalchemy import Column, Text, Float, Index
from sqlalchemy import BindParameter, Column, Dialect, Text, Float, Index, func
from sqlalchemy.orm import relationship, Mapped
import uuid
from sqlalchemy.dialects.sqlite import TEXT
from sqlalchemy.dialects.sqlite import TEXT, NUMERIC, JSON, INTEGER
from sqlalchemy.types import UserDefinedType


class PointType(UserDefinedType):
"""Defines a geopoint type.

It also sets the type as a pydantic type when plugged into TypeAdapter.
"""

def get_col_spec(self):
return "POINT"

def result_processor(self, dialect: Dialect, coltype: Any) -> Any | None:
def process(value):
if not value:
return None
parsed_value = value[6:-1].split()
return tuple(map(float, parsed_value))

return process

def bind_processor(self, dialect: Dialect) -> _BindProcessorType | None:
def process(value):
if not value:
return None
lat, lng = value
return f"POINT({lat} {lng})"

return process

def bind_expression(self, bindvalue: BindParameter) -> ColumnElement | None:
return func.GeomFromText(bindvalue, type_=self)

@classmethod
def __get_pydantic_core_schema__(
cls, _source_type: Tuple, handler: GetCoreSchemaHandler
) -> CoreSchema:
"""Pydantic validates the data as a tuple."""
return core_schema.no_info_after_validator_function(cls, handler(tuple))

@classmethod
def pydantic_adapter(cls) -> TypeAdapter:
return TypeAdapter(cls)


def generate_uuid() -> str:
return str(uuid.uuid4())


### These tables map to the domain models for Patient
class Patient(SQLModel, table=True):
patient_id: str = Field(
sa_column=Column(
TEXT, unique=True, primary_key=True, default=str(uuid.uuid4())
),
sa_column=Column(TEXT, unique=True, primary_key=True, default=generate_uuid),
allow_mutation=False,
)
queries: Mapped[List["Query"]] = Relationship(
# back_populates="patient",
passive_deletes="all",
cascade_delete=True,
sa_relationship=relationship(back_populates="patient"),
Expand All @@ -61,9 +111,7 @@ class Query(SQLModel, table=True):
"""Every Query must have both a Patient and a Location."""

query_id: str = Field(
sa_column=Column(
TEXT, unique=True, primary_key=True, default=str(uuid.uuid4())
),
sa_column=Column(TEXT, unique=True, primary_key=True, default=generate_uuid),
allow_mutation=False,
)
query: str = Field(allow_mutation=False, sa_column=Column(Text))
Expand All @@ -80,26 +128,46 @@ class Location(SQLModel, table=True):
Index("ix_location_composite_lat_lng", "latitude", "longitude", unique=True),
)
location_id: str = Field(
sa_column=Column(
TEXT, unique=True, primary_key=True, default=str(uuid.uuid4())
),
sa_column=Column(TEXT, unique=True, primary_key=True, default=generate_uuid),
allow_mutation=False,
)
latitude: float = Field(sa_column=Column(Float))
longitude: float = Field(sa_column=Column(Float))
queries: Mapped[List["Query"]] = Relationship(
# back_populates="location",
cascade_delete=True,
passive_deletes=True,
sa_relationship=relationship(back_populates="location"),
)


# TODO: Define Provider SQL model fields
# class Provider(SQLModel, table=True):
# # TODO: Compare with Github issue, domain model and noccodb
# ...
###


### These tables map to the domain models for Provider
class Provider(SQLModel, table=True):
provider_id: str = Field(
sa_column=Column(TEXT, unique=True, primary_key=True, default=generate_uuid),
allow_mutation=False,
)
name: str = Field(
sa_column=Column(TEXT, nullable=False),
)
address: str = Field(sa_column=Column(TEXT, nullable=False), allow_mutation=False)
geopoint: Annotated[
tuple, lambda geom: PointType.pydantic_adapter().validate_python(geom)
] = Field(sa_column=Column(PointType, nullable=False), allow_mutation=False)
contact: int = Field(sa_column=Column(NUMERIC, nullable=False))
facility_type: str = Field(sa_column=Column(TEXT, nullable=False))
ownership_type: str = Field(sa_column=Column(TEXT, nullable=False))
specialties: List[str] = Field(sa_column=Column(JSON, nullable=False))
stars: int = Field(sa_column=Column(INTEGER, nullable=False, default=0))
reviews: int = Field(sa_column=Column(INTEGER, nullable=False, default=0))
available_doctors: List[Dict[str, str | int | float | list]] = Field(
sa_column=Column(JSON, nullable=False, default=json.dumps([]))
)


###

# TODO: Add Model events for database ops during testing
# @event.listens_for(Query, "after_delete")
Expand Down
Loading
Loading