diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..b694934fb --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.venv \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..526c8a38d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore index 41e72f6ae..818dc6e32 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,4 @@ migrate.sh # deployment files -app.yaml -alembic.ini \ No newline at end of file +app.yaml \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..de1f0749c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + args: [ + '--count', + '--select=E9,F63,F7,F82,F401,F541,F631,F634,F701,F702', + '--show-source', + '--statistics' + ] + + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.10.0 # Use the latest stable version or pin to your preference + # hooks: + # - id: mypy \ No newline at end of file diff --git a/README.md b/README.md index 218adfaaa..9303b01d1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ uv venv source .venv/bin/activate uv pip install -r requirements.txt +# Set up pre-commit hooks +pre-commit install + # Set up environment variables cp .env.example .env # Edit `.env` to configure database connection and app settings diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 000000000..8507f6273 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,141 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README index fdacc05f6..98e4f9c44 100644 --- a/alembic/README +++ b/alembic/README @@ -1 +1 @@ -pyproject configuration, based on the generic configuration. \ No newline at end of file +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py index 9f73c07a4..89ba72be3 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,9 +1,10 @@ +from alembic import context +from dotenv import load_dotenv from logging.config import fileConfig - +from os import environ from sqlalchemy import engine_from_config from sqlalchemy import pool -from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -19,35 +20,46 @@ # from myapp import mymodel # from db import Base # Import your Base from models/__init__.py -from db import * +from db import Base # target_metadata = mymodel.Base.metadata target_metadata = Base.metadata +model_tables = set(target_metadata.tables.keys()) # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. +load_dotenv() -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. +# Fallback to environment variables for PostgreSQL connection +user = environ.get("POSTGRES_USER", None) +password = environ.get("POSTGRES_PASSWORD", None) +db = environ.get("POSTGRES_DB", None) +host = environ.get("POSTGRES_HOST", None) +port = environ.get("POSTGRES_PORT", None) +SQLALCHEMY_DATABASE_URL = f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{db}" - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. +config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL) - Calls to context.execute() here emit the given string to the - script output. - """ +def include_object(object, name, type_, reflected, compare_to): + # only include tables in sql alchemy model, not auto-generated tables from PostGIS or TIGER + if type_ == "table": + return name in model_tables + return True + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + include_object=include_object, ) with context.begin_transaction(): @@ -68,8 +80,11 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) - + context.configure( + connection=connection, + target_metadata=target_metadata, + include_object=include_object, + ) with context.begin_transaction(): context.run_migrations() diff --git a/alembic/script.py.mako b/alembic/script.py.mako index 480b130d6..40b87b8a7 100644 --- a/alembic/script.py.mako +++ b/alembic/script.py.mako @@ -8,12 +8,14 @@ Create Date: ${create_date} from typing import Sequence, Union from alembic import op +import geoalchemy2 import sqlalchemy as sa +import sqlalchemy_utils ${imports if imports else ""} # revision identifiers, used by Alembic. revision: str = ${repr(up_revision)} -down_revision: Union[str, None] = ${repr(down_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} diff --git a/alembic/versions/5901f059248a_initial_migration.py b/alembic/versions/5901f059248a_initial_migration.py new file mode 100644 index 000000000..7a8ee7cb9 --- /dev/null +++ b/alembic/versions/5901f059248a_initial_migration.py @@ -0,0 +1,628 @@ +"""Initial migration + +Revision ID: 5901f059248a +Revises: +Create Date: 2025-07-22 11:32:48.826352 + +""" + +from typing import Sequence, Union + +from alembic import op +import geoalchemy2 +import sqlalchemy as sa +import sqlalchemy_utils + + +# revision identifiers, used by Alembic. +revision: str = "5901f059248a" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "asset", + sa.Column("filename", sa.String(), nullable=True), + sa.Column("storage_service", sa.String(), nullable=True), + sa.Column("storage_path", sa.String(), nullable=True), + sa.Column("mime_type", sa.String(), nullable=True), + sa.Column("size", sa.Integer(), nullable=True), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_asset_search_vector", + "asset", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "lexicon_category", + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "lexicon_term", + sa.Column("term", sa.String(length=100), nullable=False), + sa.Column("definition", sa.String(length=255), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("term"), + ) + op.create_table( + "pub_author", + sa.Column("name", sa.String(), nullable=False), + sa.Column("affiliation", sa.String(), nullable=True), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_pub_author_search_vector", + "pub_author", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "sensor", + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("model", sa.String(length=50), nullable=True), + sa.Column("serial_no", sa.String(length=50), nullable=True), + sa.Column("date_installed", sa.DateTime(), nullable=True), + sa.Column("date_removed", sa.DateTime(), nullable=True), + sa.Column("recording_interval", sa.Integer(), nullable=True), + sa.Column("notes", sa.String(length=50), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("username", sa.String(length=255), nullable=False), + sa.Column("password", sa.String(length=255), nullable=False), + sa.Column("is_superuser", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("avatar_url", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "contact", + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("role", sa.String(length=100), nullable=False), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["role"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_contact_search_vector", + "contact", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "geochronology_age", + sa.Column("location_id", sa.Integer(), nullable=False), + sa.Column("age", sa.Float(), nullable=False), + sa.Column("age_error", sa.Float(), nullable=True), + sa.Column("method", sa.String(length=100), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["method"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "groundwater_level_sensor", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("sensor_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["sensor_id"], ["sensor.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("sensor_id"), + ) + op.create_table( + "lexicon_term_category_association", + sa.Column("lexicon_term", sa.String(length=100), nullable=False), + sa.Column("category_name", sa.String(length=255), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["category_name"], ["lexicon_category.name"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["lexicon_term"], ["lexicon_term.term"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "lexicon_triple", + sa.Column("subject", sa.String(length=100), nullable=False), + sa.Column("predicate", sa.String(length=100), nullable=False), + sa.Column("object_", sa.String(length=100), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["object_"], ["lexicon_term.term"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["subject"], ["lexicon_term.term"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "location", + sa.Column("notes", sa.Text(), nullable=True), + sa.Column( + "point", + geoalchemy2.types.Geometry( + geometry_type="POINT", + srid=4326, + from_text="ST_GeomFromEWKT", + name="geometry", + nullable=False, + ), + nullable=False, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("release_status", sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint( + ["release_status"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.execute("DROP INDEX IF EXISTS idx_location_point;") + op.create_index( + "idx_location_point", + "location", + ["point"], + unique=False, + postgresql_using="gist", + ) + op.create_table( + "publication", + sa.Column("title", sa.Text(), nullable=False), + sa.Column("abstract", sa.Text(), nullable=True), + sa.Column("doi", sa.String(), nullable=True), + sa.Column("year", sa.Integer(), nullable=True), + sa.Column("publisher", sa.String(), nullable=True), + sa.Column("url", sa.String(), nullable=True), + sa.Column("publication_type", sa.String(length=100), nullable=False), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["publication_type"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("doi"), + ) + op.create_index( + "ix_publication_search_vector", + "publication", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "thing", + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("release_status", sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint( + ["release_status"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "address", + sa.Column("contact_id", sa.Integer(), nullable=False), + sa.Column("address_line_1", sa.String(length=255), nullable=False), + sa.Column("address_line_2", sa.String(length=255), nullable=True), + sa.Column("city", sa.String(length=100), nullable=False), + sa.Column("state", sa.String(length=50), nullable=False), + sa.Column("postal_code", sa.String(length=20), nullable=False), + sa.Column("country", sa.String(length=100), nullable=True), + sa.Column("address_type", sa.String(length=100), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["address_type"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint(["contact_id"], ["contact.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "asset_thing_association", + sa.Column("asset_id", sa.Integer(), nullable=False), + sa.Column("thing_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["asset_id"], ["asset.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["thing_id"], ["thing.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "email", + sa.Column("contact_id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=100), nullable=False), + sa.Column("email_type", sa.String(length=100), nullable=True), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["contact_id"], ["contact.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["email_type"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_email_search_vector", + "email", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "location_thing_association", + sa.Column("location_id", sa.Integer(), nullable=False), + sa.Column("thing_id", sa.Integer(), nullable=False), + sa.Column( + "effective_start", + sa.DateTime(), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("effective_end", sa.DateTime(), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["location_id"], ["location.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["thing_id"], ["thing.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("location_id", "thing_id", "id"), + ) + op.create_table( + "phone", + sa.Column("contact_id", sa.Integer(), nullable=False), + sa.Column("phone_number", sa.String(length=20), nullable=False), + sa.Column("phone_type", sa.String(length=100), nullable=True), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["contact_id"], ["contact.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["phone_type"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_phone_search_vector", + "phone", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "pub_author_contact_association", + sa.Column("author_id", sa.Integer(), nullable=False), + sa.Column("contact_id", sa.Integer(), nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["author_id"], ["pub_author.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["contact_id"], ["contact.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("author_id", "contact_id"), + ) + op.create_table( + "pub_author_publication_association", + sa.Column("publication_id", sa.Integer(), nullable=False), + sa.Column("author_id", sa.Integer(), nullable=False), + sa.Column("author_order", sa.Integer(), nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["author_id"], ["pub_author.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["publication_id"], ["publication.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("publication_id", "author_id"), + ) + op.create_table( + "series", + sa.Column("observed_property", sa.String(length=100), nullable=False), + sa.Column("unit", sa.String(length=100), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("sensor_id", sa.Integer(), nullable=True), + sa.Column("thing_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("release_status", sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint( + ["observed_property"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["release_status"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint(["sensor_id"], ["sensor.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["thing_id"], ["thing.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["unit"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "spring_thing", + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("thing_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["thing_id"], ["thing.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("thing_id"), + ) + op.create_table( + "thing_contact_association", + sa.Column("thing_id", sa.Integer(), nullable=False), + sa.Column("contact_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["contact_id"], + ["contact.id"], + ), + sa.ForeignKeyConstraint( + ["thing_id"], + ["thing.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "thing_id_link", + sa.Column("thing_id", sa.Integer(), nullable=True), + sa.Column("relation", sa.String(length=100), nullable=False), + sa.Column("alternate_id", sa.String(length=100), nullable=False), + sa.Column("alternate_organization", sa.String(length=100), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["alternate_organization"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["relation"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint(["thing_id"], ["thing.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "well_thing", + sa.Column("well_depth", sa.Float(), nullable=True), + sa.Column("hole_depth", sa.Float(), nullable=True), + sa.Column("well_type", sa.String(length=100), nullable=True), + sa.Column("casing_diameter", sa.Float(), nullable=True), + sa.Column("casing_depth", sa.Float(), nullable=True), + sa.Column("casing_description", sa.String(length=50), nullable=True), + sa.Column("construction_notes", sa.String(length=250), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("thing_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["thing_id"], ["thing.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["well_type"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("thing_id"), + ) + op.create_table( + "collaborative_network_well", + sa.Column("actively_monitored", sa.Boolean(), nullable=False), + sa.Column("well_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["well_id"], ["well_thing.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "geochemical_series", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("series_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["series_id"], ["series.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("series_id"), + ) + op.create_table( + "geothermal_series", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("series_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["series_id"], ["series.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("series_id"), + ) + op.create_table( + "groundwater_level_series", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("series_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["series_id"], ["series.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("series_id"), + ) + op.create_table( + "well_screen", + sa.Column("well_id", sa.Integer(), nullable=False), + sa.Column("screen_depth_top", sa.Float(), nullable=False), + sa.Column("screen_depth_bottom", sa.Float(), nullable=False), + sa.Column("screen_type", sa.String(length=100), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["screen_type"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint(["well_id"], ["well_thing.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("well_screen") + op.drop_table("groundwater_level_series") + op.drop_table("geothermal_series") + op.drop_table("geochemical_series") + op.drop_table("collaborative_network_well") + op.drop_table("well_thing") + op.drop_table("thing_id_link") + op.drop_table("thing_contact_association") + op.drop_table("spring_thing") + op.drop_table("series") + op.drop_table("pub_author_publication_association") + op.drop_table("pub_author_contact_association") + op.drop_index("ix_phone_search_vector", table_name="phone", postgresql_using="gin") + op.drop_table("phone") + op.drop_table("location_thing_association") + op.drop_index("ix_email_search_vector", table_name="email", postgresql_using="gin") + op.drop_table("email") + op.drop_table("asset_thing_association") + op.drop_table("address") + op.drop_table("thing") + op.drop_index( + "ix_publication_search_vector", table_name="publication", postgresql_using="gin" + ) + op.drop_table("publication") + op.drop_index("idx_location_point", table_name="location", postgresql_using="gist") + op.drop_table("location") + op.drop_table("lexicon_triple") + op.drop_table("lexicon_term_category_association") + op.drop_table("groundwater_level_sensor") + op.drop_table("geochronology_age") + op.drop_index( + "ix_contact_search_vector", table_name="contact", postgresql_using="gin" + ) + op.drop_table("contact") + op.drop_table("user") + op.drop_table("sensor") + op.drop_index( + "ix_pub_author_search_vector", table_name="pub_author", postgresql_using="gin" + ) + op.drop_table("pub_author") + op.drop_table("lexicon_term") + op.drop_table("lexicon_category") + op.drop_index("ix_asset_search_vector", table_name="asset", postgresql_using="gin") + op.drop_table("asset") + # ### end Alembic commands ### diff --git a/alembic/versions/8447b9ebaf29_changes_to_pre_production.py b/alembic/versions/8447b9ebaf29_changes_to_pre_production.py new file mode 100644 index 000000000..956add039 --- /dev/null +++ b/alembic/versions/8447b9ebaf29_changes_to_pre_production.py @@ -0,0 +1,87 @@ +"""changes to pre-production + +Revision ID: 8447b9ebaf29 +Revises: 5901f059248a +Create Date: 2025-07-22 12:17:19.076090 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + + +# revision identifiers, used by Alembic. +revision: str = "8447b9ebaf29" +down_revision: Union[str, Sequence[str], None] = "5901f059248a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "address", + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + ) + op.create_index( + "ix_address_search_vector", + "address", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.add_column( + "thing", + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + ) + op.create_index( + "ix_thing_search_vector", + "thing", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.add_column( + "well_thing", + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + ) + op.create_index( + "ix_well_thing_search_vector", + "well_thing", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "ix_well_thing_search_vector", table_name="well_thing", postgresql_using="gin" + ) + op.drop_column("well_thing", "search_vector") + op.drop_index("ix_thing_search_vector", table_name="thing", postgresql_using="gin") + op.drop_column("thing", "search_vector") + op.drop_index( + "ix_address_search_vector", table_name="address", postgresql_using="gin" + ) + op.drop_column("address", "search_vector") + # ### end Alembic commands ### diff --git a/db/engine.py b/db/engine.py index a51c3f7c1..e084fac6a 100644 --- a/db/engine.py +++ b/db/engine.py @@ -15,6 +15,7 @@ # =============================================================================== import asyncio +from dotenv import load_dotenv import os from contextlib import contextmanager from sqlalchemy import ( @@ -26,6 +27,7 @@ ) from sqlalchemy.util import await_only +load_dotenv() driver = os.environ.get("DB_DRIVER", "") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..ebce2102d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +# keep docker-compose.yml in root directory to configure with root .env + +services: + db: + image: postgis/postgis:17-3.5 + platform: linux/amd64 + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + ports: + - 5432:5432 + volumes: + - postgres_data:/var/lib/postgresql/data + + app: + build: + context: . + dockerfile: ./docker/app/Dockerfile + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_HOST=db + ports: + - 8000:8000 + depends_on: + - db + links: + - db + volumes: + - .:/app + +volumes: + postgres_data: \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 51b1f8c5c..000000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,222 +0,0 @@ -# =========================================================================== -# Tools -ARG GO_VERSION=1.22.4 -FROM golang:${GO_VERSION}-alpine AS tools - -ENV TOOLS_VERSION 0.8.1 - -RUN apk update && apk add --no-cache git gcc musl-dev \ - && go install github.com/timescale/timescaledb-tune/cmd/timescaledb-tune@latest \ - && go install github.com/timescale/timescaledb-parallel-copy/cmd/timescaledb-parallel-copy@latest -# =========================================================================== - -#ARG BASE_IMAGE=postgres:17-alpine3.22 -FROM postgres:17-alpine3.22 - -#LABEL maintainer="PostGIS Project - https://postgis.net" \ -# org.opencontainers.image.description="PostGIS 3.5.2+dfsg-1.pgdg110+1 spatial database extension with PostgreSQL 17 bullseye" \ -# org.opencontainers.image.source="https://github.com/postgis/docker-postgis" - -ENV POSTGIS_MAJOR 3 -ENV POSTGIS_VERSION 3.5.2+dfsg-1.pgdg110+1 - -ARG ALPINE_VERSION=3.21 -ARG CLANG_VERSION=19 -ARG PG_MAJOR=17 -ARG PG_VERSION=17 -ARG PG_MAJOR_VERSION=17 -ARG PGVECTOR_VERSION='v0.7.2' - - -#LABEL maintainer="PostGIS Project - https://postgis.net" \ -# org.opencontainers.image.description="PostGIS 3.5.3 spatial database extension with PostgreSQL 17 Alpine" \ -# org.opencontainers.image.source="https://github.com/postgis/docker-postgis" - -ENV POSTGIS_VERSION 3.5.3 -ENV POSTGIS_SHA256 44222ed2b8f742ffc1ceb429b09ebb484c7880f9ba27bf7b6b197346cdd25437 - -RUN set -eux \ - && apk add --no-cache --virtual .fetch-deps \ - ca-certificates \ - openssl \ - tar \ - \ - && wget -O postgis.tar.gz "https://github.com/postgis/postgis/archive/${POSTGIS_VERSION}.tar.gz" \ - && echo "${POSTGIS_SHA256} *postgis.tar.gz" | sha256sum -c - \ - && mkdir -p /usr/src/postgis \ - && tar \ - --extract \ - --file postgis.tar.gz \ - --directory /usr/src/postgis \ - --strip-components 1 \ - && rm postgis.tar.gz \ - \ - && apk add --no-cache --virtual .build-deps \ - \ - gdal-dev \ - geos-dev \ - proj-dev \ - proj-util \ - sfcgal-dev \ - \ - # The upstream variable, '$DOCKER_PG_LLVM_DEPS' contains - # the correct versions of 'llvm-dev' and 'clang' for the current version of PostgreSQL. - # This improvement has been discussed in https://github.com/docker-library/postgres/pull/1077 - $DOCKER_PG_LLVM_DEPS \ - \ - autoconf \ - automake \ - cunit-dev \ - file \ - g++ \ - gcc \ - gettext-dev \ - git \ - json-c-dev \ - libtool \ - libxml2-dev \ - make \ - pcre2-dev \ - perl \ - protobuf-c-dev \ - \ -# build PostGIS - with Link Time Optimization (LTO) enabled - && cd /usr/src/postgis \ - && gettextize \ - && ./autogen.sh \ - && ./configure \ - --enable-lto \ - && make -j$(nproc) \ - && make install \ - \ -# This section is for refreshing the proj data for the regression tests. -# It serves as a workaround for an issue documented at https://trac.osgeo.org/postgis/ticket/5316 -# This increases the Docker image size by about 1 MB. - && projsync --system-directory --file ch_swisstopo_CHENyx06_ETRS \ - && projsync --system-directory --file us_noaa_eshpgn \ - && projsync --system-directory --file us_noaa_prvi \ - && projsync --system-directory --file us_noaa_wmhpgn \ -# This section performs a regression check. - && mkdir /tempdb \ - && chown -R postgres:postgres /tempdb \ - && su postgres -c 'pg_ctl -D /tempdb init' \ - && su postgres -c 'pg_ctl -D /tempdb -c -l /tmp/logfile -o '-F' start ' \ - && cd regress \ - && make -j$(nproc) check RUNTESTFLAGS="--extension --verbose" PGUSER=postgres \ - \ - && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS postgis;"' \ - && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS postgis_raster;"' \ - && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS postgis_sfcgal;"' \ - && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; --needed for postgis_tiger_geocoder "' \ - && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS address_standardizer;"' \ - && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS address_standardizer_data_us;"' \ - && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder;"' \ - && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS postgis_topology;"' \ - && su postgres -c 'psql -t -c "SELECT version();"' >> /_pgis_full_version.txt \ - && su postgres -c 'psql -t -c "SELECT PostGIS_Full_Version();"' >> /_pgis_full_version.txt \ - && su postgres -c 'psql -t -c "\dx"' >> /_pgis_full_version.txt \ - \ - && su postgres -c 'pg_ctl -D /tempdb --mode=immediate stop' \ - && rm -rf /tempdb \ - && rm -rf /tmp/logfile \ - && rm -rf /tmp/pgis_reg \ -# add .postgis-rundeps - && apk add --no-cache --virtual .postgis-rundeps \ - \ - gdal \ - geos \ - proj \ - sfcgal \ - \ - json-c \ - libstdc++ \ - pcre2 \ - protobuf-c \ - \ - # ca-certificates: for accessing remote raster files - # fix https://github.com/postgis/docker-postgis/issues/307 - ca-certificates \ -# clean - && cd / \ - && rm -rf /usr/src/postgis \ - && apk del .fetch-deps .build-deps \ -# At the end of the build, we print the collected information -# from the '/_pgis_full_version.txt' file. This is for experimental and internal purposes. - && cat /_pgis_full_version.txt - - -RUN mkdir -p /docker-entrypoint-initdb.d -COPY ./postgis.d/initdb-postgis.sh /docker-entrypoint-initdb.d/10_postgis.sh -COPY ./postgis.d/update-postgis.sh /usr/local/bin - -#=========================================================================== -# TimescaleDB -ARG OSS_ONLY - -#LABEL maintainer="Timescale https://www.timescale.com" - - -RUN set -ex; \ - echo "https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/community/" >> /etc/apk/repositories; \ - apk update; \ - if [ "$PG_MAJOR_VERSION" -ge 16 ] ; then \ - apk add --no-cache postgresql${PG_VERSION}-plpython3; \ - fi - - - -RUN set -ex; \ - apk update; \ - apk add --no-cache --virtual .vector-deps \ - postgresql${PG_VERSION}-dev \ - git \ - build-base \ - clang${CLANG_VERSION} \ - llvm${CLANG_VERSION}-dev \ - llvm${CLANG_VERSION}; \ - git clone --branch ${PGVECTOR_VERSION} https://github.com/pgvector/pgvector.git /build/pgvector; \ - cd /build/pgvector; \ - make; \ - make install; \ - apk del .vector-deps; - -COPY timescaledb.d/* /docker-entrypoint-initdb.d/ -COPY --from=tools /go/bin/* /usr/local/bin/ -#COPY --from=oldversions /usr/local/lib/postgresql/timescaledb-*.so /usr/local/lib/postgresql/ -#COPY --from=oldversions /usr/local/share/postgresql/extension/timescaledb--*.sql /usr/local/share/postgresql/extension/ - -ARG TS_VERSION -RUN set -ex \ - && apk add --no-cache --virtual .fetch-deps \ - ca-certificates \ - git \ - openssl \ - openssl-dev \ - tar \ - && mkdir -p /build/ \ - && git clone https://github.com/timescale/timescaledb /build/timescaledb \ - \ - && apk add --no-cache --virtual .build-deps \ - coreutils \ - dpkg-dev dpkg \ - gcc \ - krb5-dev \ - libc-dev \ - make \ - cmake \ - util-linux-dev \ - \ - # Build current version \ - && cd /build/timescaledb && rm -fr build \ - && git checkout ${TS_VERSION} \ - && ./bootstrap -DCMAKE_BUILD_TYPE=RelWithDebInfo -DREGRESS_CHECKS=OFF -DTAP_CHECKS=OFF -DGENERATE_DOWNGRADE_SCRIPT=ON -DWARNINGS_AS_ERRORS=OFF -DPROJECT_INSTALL_METHOD="docker"${OSS_ONLY} \ - && cd build && make install \ - && cd ~ \ - \ - && if [ "${OSS_ONLY}" != "" ]; then rm -f $(pg_config --pkglibdir)/timescaledb-tsl-*.so; fi \ - && apk del .fetch-deps .build-deps \ - && rm -rf /build \ - && sed -r -i "s/[#]*\s*(shared_preload_libraries)\s*=\s*'(.*)'/\1 = 'timescaledb,\2'/;s/,'/'/" /usr/local/share/postgresql/postgresql.conf.sample - - -LABEL org.opencontainers.image.source="https://github.com/DataIntegrationGroup/NMSampleLocations" diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile new file mode 100644 index 000000000..414203128 --- /dev/null +++ b/docker/app/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.13-slim AS base + +# install system dependencies for psycopg2 and uv +RUN apt-get update && apt-get install -y curl gcc libpq-dev + +# install curl +RUN apt-get update && apt-get install -y curl + +# install uv (fast dependency manager) +RUN curl -Ls https://astral.sh/uv/install.sh | sh -s -- v0.7.17 && \ + cp /root/.local/bin/uv /usr/local/bin/uv + +# install postgresql client +RUN apt-get update && apt-get install -y postgresql-client + +# set workdir +WORKDIR /app + +# copy the full project source +COPY . . + +# install dependencies using uv +ENV UV_PROJECT_ENVIRONMENT="/usr/local/" +RUN uv sync --locked + +# expose FastAPI's default dev port +EXPOSE 8000 + +# set environment variables for database connection +RUN chmod +x entrypoint.sh + +# default command (run database migrations and the FastAPI development server) +CMD ["sh", "entrypoint.sh"] diff --git a/docker/postgis.d/initdb-postgis.sh b/docker/postgis.d/initdb-postgis.sh deleted file mode 100644 index eb5626334..000000000 --- a/docker/postgis.d/initdb-postgis.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -set -e - -# Perform all actions as $POSTGRES_USER -export PGUSER="$POSTGRES_USER" - -# Create the 'template_postgis' template db -"${psql[@]}" <<- 'EOSQL' -CREATE DATABASE template_postgis IS_TEMPLATE true; -EOSQL - -# Load PostGIS into both template_database and $POSTGRES_DB -for DB in template_postgis "$POSTGRES_DB"; do - echo "Loading PostGIS extensions into $DB" - "${psql[@]}" --dbname="$DB" <<-'EOSQL' - CREATE EXTENSION IF NOT EXISTS postgis; - CREATE EXTENSION IF NOT EXISTS postgis_topology; - -- Reconnect to update pg_setting.resetval - -- See https://github.com/postgis/docker-postgis/issues/288 - \c - CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; - CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder; -EOSQL -done \ No newline at end of file diff --git a/docker/postgis.d/update-postgis.sh b/docker/postgis.d/update-postgis.sh deleted file mode 100644 index ff4b2e7cd..000000000 --- a/docker/postgis.d/update-postgis.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/sh - -set -e - -# Perform all actions as $POSTGRES_USER -export PGUSER="$POSTGRES_USER" - -POSTGIS_VERSION="${POSTGIS_VERSION%%+*}" - -# Load PostGIS into both template_database and $POSTGRES_DB -for DB in template_postgis "$POSTGRES_DB" "${@}"; do - echo "Updating PostGIS extensions '$DB' to $POSTGIS_VERSION" - psql --dbname="$DB" -c " - -- Upgrade PostGIS (includes raster) - CREATE EXTENSION IF NOT EXISTS postgis VERSION '$POSTGIS_VERSION'; - ALTER EXTENSION postgis UPDATE TO '$POSTGIS_VERSION'; - - -- Upgrade Topology - CREATE EXTENSION IF NOT EXISTS postgis_topology VERSION '$POSTGIS_VERSION'; - ALTER EXTENSION postgis_topology UPDATE TO '$POSTGIS_VERSION'; - - -- Install Tiger dependencies in case not already installed - CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; - -- Upgrade US Tiger Geocoder - CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder VERSION '$POSTGIS_VERSION'; - ALTER EXTENSION postgis_tiger_geocoder UPDATE TO '$POSTGIS_VERSION'; - " -done \ No newline at end of file diff --git a/docker/timescaledb.d/000_install_timescaledb.sh b/docker/timescaledb.d/000_install_timescaledb.sh deleted file mode 100644 index 38823021f..000000000 --- a/docker/timescaledb.d/000_install_timescaledb.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -create_sql=`mktemp` - -if [ -z "${POSTGRESQL_CONF_DIR:-}" ]; then - POSTGRESQL_CONF_DIR=${PGDATA} -fi - -cat <${create_sql} -CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; -EOF - -TS_TELEMETRY='basic' -if [ "${TIMESCALEDB_TELEMETRY:-}" == "off" ]; then - TS_TELEMETRY='off' - - # We delete the job as well to ensure that we do not spam the - # log with other messages related to the Telemetry job. - cat <>${create_sql} -SELECT alter_job(1,scheduled:=false); -EOF -fi - -echo "timescaledb.telemetry_level=${TS_TELEMETRY}" >> ${POSTGRESQL_CONF_DIR}/postgresql.conf - -if [ -z "${POSTGRESQL_PASSWORD:-}" ]; then - POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD:-} -fi -export PGPASSWORD="$POSTGRESQL_PASSWORD" - -# create extension timescaledb in initial databases -psql -U "${POSTGRES_USER}" postgres -f ${create_sql} -psql -U "${POSTGRES_USER}" template1 -f ${create_sql} - -if [ "${POSTGRES_DB:-postgres}" != 'postgres' ]; then - psql -U "${POSTGRES_USER}" "${POSTGRES_DB}" -f ${create_sql} -fi \ No newline at end of file diff --git a/docker/timescaledb.d/001_timescaledb_tune.sh b/docker/timescaledb.d/001_timescaledb_tune.sh deleted file mode 100644 index 95cc6db94..000000000 --- a/docker/timescaledb.d/001_timescaledb_tune.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/bin/bash - -NO_TS_TUNE=${NO_TS_TUNE:-""} -TS_TUNE_MEMORY=${TS_TUNE_MEMORY:-""} -TS_TUNE_NUM_CPUS=${TS_TUNE_NUM_CPUS:-""} -TS_TUNE_MAX_CONNS=${TS_TUNE_MAX_CONNS:-""} -TS_TUNE_MAX_BG_WORKERS=${TS_TUNE_MAX_BG_WORKERS:-""} -TS_TUNE_WAL_DISK_SIZE=${TS_TUNE_WAL_DISK_SIZE:-""} - -if [ ! -z "${NO_TS_TUNE:-}" ]; then - # The user has explicitly requested not to run timescaledb-tune; exit this script - exit 0 -fi - - -if [ -z "${POSTGRESQL_CONF_DIR:-}" ]; then - POSTGRESQL_CONF_DIR=${PGDATA} -fi - -if [ -z "${TS_TUNE_MEMORY:-}" ]; then - # See if we can get the container's total allocated memory from the cgroups metadata - # Try with cgroups v2 first. - if [ -f /sys/fs/cgroup/cgroup.controllers ]; then - TS_TUNE_MEMORY=$(cat /sys/fs/cgroup/memory.max) - case ${TS_TUNE_MEMORY} in - max) - TS_TUNE_MEMORY="" - ;; - *) - TS_CGROUPS_MAX_MEM=true - ;; - esac - # cgroups v2 is not available, try with cgroups v1 - elif [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then - TS_TUNE_MEMORY=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) - TS_CGROUPS_MAX_MEM=true - fi - if [ "${TS_CGROUPS_MAX_MEM:-false}" != "false" ]; then - if [ "${TS_TUNE_MEMORY}" = "18446744073709551615" ]; then - # Bash seems to error out for numbers greater than signed 64-bit, - # so if the value of limit_in_bytes is the 64-bit UNSIGNED max value - # we should just bail out and hope timescaledb-tune can figure this - # out. If we don't, the next comparison is likely going to fail - # or it might store a negative value which will crash later. - TS_TUNE_MEMORY="" - fi - - FREE_KB=$(grep MemTotal: /proc/meminfo | awk '{print $2}') - FREE_BYTES=$(( ${FREE_KB} * 1024 )) - if [ ${TS_TUNE_MEMORY} -gt ${FREE_BYTES} ]; then - # Something weird is going on if the cgroups memory limit exceeds the total available - # amount of system memory reported by "free", which is the total amount of memory available on the host. - # Most likely, it is this issue: https://github.com/moby/moby/issues/18087 (if no limit is - # set, the max limit is set to the max 64 bit integer). In this case, we just leave - # TS_TUNE_MEMORY blank and let timescaledb-tune derive the memory itself using syscalls. - TS_TUNE_MEMORY="" - else - # Convert the bytes to MB so it plays nicely with timescaledb-tune - TS_TUNE_MEMORY="$(echo ${TS_TUNE_MEMORY} | awk '{print int($1 / 1024 / 1024)}')MB" - fi - fi -fi - -if [ -z "${TS_TUNE_NUM_CPUS:-}" ]; then - # See if we can get the container's available CPUs from the cgroups metadata - # Try with cgroups v2 first. - if [ -f /sys/fs/cgroup/cgroup.controllers ]; then - TS_TUNE_NUM_CPUS=$(cat /sys/fs/cgroup/cpu.max | awk '{print $1}') - if [ "${TS_TUNE_NUM_CPUS}" = "max" ]; then - TS_TUNE_NUM_CPUS="" - else - TS_TUNE_NUM_CPUS_PERIOD=$(cat /sys/fs/cgroup/cpu.max | awk '{print $2}') - fi - # cgroups v2 is not available, try with cgroups v1 - elif [ -f /sys/fs/cgroup/cpu/cpu.cfs_quota_us ]; then - TS_TUNE_NUM_CPUS=$(cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us) - if [ "${TS_TUNE_NUM_CPUS}" = "-1" ]; then - TS_TUNE_NUM_CPUS="" - else - TS_TUNE_NUM_CPUS_PERIOD=$(cat /sys/fs/cgroup/cpu/cpu.cfs_period_us) - fi - fi - - if [ -n "${TS_TUNE_NUM_CPUS}" ]; then - if [ "${TS_TUNE_NUM_CPUS_PERIOD}" != "100000" ]; then - # Detecting cpu via cgroups with modified duration is not supported - TS_TUNE_NUM_CPUS="" - else - # Determine (integer) number of CPUs, rounding up - if [ $(( ${TS_TUNE_NUM_CPUS} % 100000 )) -eq 0 ]; then - TS_TUNE_NUM_CPUS=$(( ${TS_TUNE_NUM_CPUS} / 100000 )) - else - TS_TUNE_NUM_CPUS=$(( ( ${TS_TUNE_NUM_CPUS} / 100000 ) + 1 )) - fi - fi - fi -fi - -if [ ! -z "${TS_TUNE_MEMORY:-}" ]; then - TS_TUNE_MEMORY_FLAGS=--memory="${TS_TUNE_MEMORY}" -fi - -if [ ! -z "${TS_TUNE_NUM_CPUS:-}" ]; then - TS_TUNE_NUM_CPUS_FLAGS=--cpus=${TS_TUNE_NUM_CPUS} -fi - -if [ ! -z "${TS_TUNE_MAX_CONNS:-}" ]; then - TS_TUNE_MAX_CONNS_FLAGS=--max-conns=${TS_TUNE_MAX_CONNS} -fi - -if [ ! -z "${TS_TUNE_MAX_BG_WORKERS:-}" ]; then - TS_TUNE_MAX_BG_WORKERS_FLAGS=--max-bg-workers=${TS_TUNE_MAX_BG_WORKERS} -fi - -if [ ! -z "${TS_TUNE_WAL_DISK_SIZE:-}" ]; then - TS_TUNE_WAL_DISK_SIZE_FLAGS=--wal-disk-size="${TS_TUNE_WAL_DISK_SIZE}" -fi - -if [ ! -z "${PG_MAJOR}" ]; then - TS_TUNE_PG_VERSION=--pg-version=${PG_MAJOR} -fi - -/usr/local/bin/timescaledb-tune --quiet --yes --conf-path="${POSTGRESQL_CONF_DIR}/postgresql.conf" ${TS_TUNE_MEMORY_FLAGS} ${TS_TUNE_NUM_CPUS_FLAGS} ${TS_TUNE_MAX_CONNS_FLAGS} ${TS_TUNE_MAX_BG_WORKERS_FLAGS} ${TS_TUNE_WAL_DISK_SIZE_FLAGS} ${TS_TUNE_PG_VERSION} \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 000000000..662487618 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Wait for PostgreSQL to be ready +until PGPASSWORD="$POSTGRES_PASSWORD" pg_isready -h db -p 5432 -U "$POSTGRES_USER"; do + echo "Waiting for postgres..." + sleep 2 +done +echo "PostgreSQL is ready!" + +echo "Applying migrations..." +alembic upgrade head +echo "Starting the application..." +uvicorn main:app --host 0.0.0.0 --port 8000 --reload \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9bcafb8d4..722ff443e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dependencies = [ "phonenumbers==9.0.8", "pillow==11.3.0", "pluggy==1.6.0", + "pre-commit==4.2.0", "propcache==0.3.2", "proto-plus==1.26.1", "protobuf==6.31.1", diff --git a/uv.lock b/uv.lock index 3a447339d..692106725 100644 --- a/uv.lock +++ b/uv.lock @@ -159,14 +159,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.0" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/9d/b1e08d36899c12c8b894a44a5583ee157789f26fc4b176f8e4b6217b56e1/authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210", size = 158371, upload-time = "2025-05-23T00:21:45.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a1/d8d1c6f8bc922c0b87ae0d933a8ed57be1bef6970894ed79c2852a153cd3/authlib-1.6.1.tar.gz", hash = "sha256:4dffdbb1460ba6ec8c17981a4c67af7d8af131231b5a36a88a1e8c80c111cdfd", size = 159988, upload-time = "2025-07-20T07:38:42.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981, upload-time = "2025-05-23T00:21:43.075Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/cc6a08053f822f98f334d38a27687b69c6655fb05cd74a7a5e70a2aeed95/authlib-1.6.1-py2.py3-none-any.whl", hash = "sha256:e9d2031c34c6309373ab845afc24168fe9e93dc52d252631f52642f21f5ed06e", size = 239299, upload-time = "2025-07-20T07:38:39.259Z" }, ] [[package]] @@ -259,6 +259,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, ] +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.2" @@ -354,6 +363,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "dnspython" version = "2.7.0" @@ -429,6 +447,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/73/ef1ab892c2d189d8b6bd72325e9e710df6737c3b7976e12aa5749a56ea01/fastapi_pagination-0.13.3-py3-none-any.whl", hash = "sha256:e1b1cc7fa5c773c61087845ef8a73ed6b516071c057418698b9242461573f44e", size = 50986, upload-time = "2025-06-25T21:22:13.591Z" }, ] +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + [[package]] name = "frozenlist" version = "1.7.0" @@ -657,6 +684,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -840,6 +876,7 @@ dependencies = [ { name = "phonenumbers" }, { name = "pillow" }, { name = "pluggy" }, + { name = "pre-commit" }, { name = "propcache" }, { name = "proto-plus" }, { name = "protobuf" }, @@ -939,6 +976,7 @@ requires-dist = [ { name = "phonenumbers", specifier = "==9.0.8" }, { name = "pillow", specifier = "==11.3.0" }, { name = "pluggy", specifier = "==1.6.0" }, + { name = "pre-commit", specifier = "==4.2.0" }, { name = "propcache", specifier = "==0.3.2" }, { name = "proto-plus", specifier = "==1.26.1" }, { name = "protobuf", specifier = "==6.31.1" }, @@ -982,6 +1020,15 @@ dev = [ { name = "python-dotenv", specifier = ">=1.1.1" }, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + [[package]] name = "numpy" version = "2.3.1" @@ -1125,6 +1172,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, ] +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1134,6 +1190,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + [[package]] name = "propcache" version = "0.3.2" @@ -1398,6 +1470,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + [[package]] name = "requests" version = "2.32.4" @@ -1605,6 +1694,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] +[[package]] +name = "virtualenv" +version = "20.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/96/0834f30fa08dca3738614e6a9d42752b6420ee94e58971d702118f7cfd30/virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0", size = 6076970, upload-time = "2025-07-21T04:09:50.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" }, +] + [[package]] name = "yarl" version = "1.20.1"