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

Add Alembic migration support to database libraries #51

Merged
merged 58 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
874afca
Add support for processing based on dialects.
aholmes Mar 6, 2024
793b224
Add tests for getting schemas.
aholmes Mar 6, 2024
c63a964
Add tests for dialect and schema methods to be used by Alembic.
aholmes Mar 7, 2024
cfff139
Initial supporting code for Alembic to run.
aholmes Mar 7, 2024
223341f
Add tests for Alembic migration bootstrapping.
aholmes Mar 8, 2024
a63b649
Set specific versions for code quality tools.
aholmes Mar 8, 2024
aba1845
Remove dist directory on `make clean`
aholmes Mar 13, 2024
50d4d05
Fix issue caused by improper handling of invalid database configurati…
aholmes Mar 15, 2024
5a2bdb1
Adjustment to Makefile.
aholmes Mar 15, 2024
f2026eb
Add reports directory.
aholmes Mar 18, 2024
201037c
Change where pytest reports end up.
aholmes Mar 18, 2024
75b1312
Fix misplaced comments.
aholmes Mar 18, 2024
ff4baca
Add junit2html to dependencies for report generation.
aholmes Mar 18, 2024
35ff5b0
Output JUnit/XUnit HTML report.
aholmes Mar 18, 2024
6339079
Change the pytest artifact path for upload.
aholmes Mar 18, 2024
5cd25d5
Fix DatabaseConfig error with optional field not having a default.
aholmes Mar 19, 2024
9558dde
Fix Pyright errors.
aholmes Mar 19, 2024
9d8d7e6
Fix DatabaseConfig error with optional field not having a default.
aholmes Mar 19, 2024
4480ad8
Fix Pyright errors.
aholmes Mar 19, 2024
6f1a189
Merge branch 'main' into aholmes-add-alembic-support
aholmes Mar 19, 2024
4a3cc2f
Rename `SUCCESS` var to `EXIT_CODE` to better represent what it is.
aholmes Mar 19, 2024
7c52ec4
Merge pull request #52 from uclahs-cds/aholmes-add-pytest-extras
aholmes Mar 19, 2024
da03a87
Add `bl-alembic` command.
aholmes Mar 19, 2024
e2e1210
Updated Alembic type stubs.
aholmes Mar 20, 2024
746048f
Some small alterations to how Alembic is called.
aholmes Mar 20, 2024
907f540
Create alembic migration directory automatically if it does not exist.
aholmes Mar 20, 2024
ce81de5
Clean up Alembic migration script.
aholmes Mar 21, 2024
2a1b7e2
Handle placement of BL_Python env scripts for Alembic.
aholmes Mar 21, 2024
a015712
Fix inconsistent error in CI/CD vs. local caused by wildcard import.
aholmes Mar 21, 2024
69077f2
Don't create an alembic.ini if it already exists but was not specific…
aholmes Mar 22, 2024
5a898bf
Don't use tempfile for bl-alembic. Fix some logic errors with file cr…
aholmes Mar 22, 2024
ad9a23a
Refactor BLAlembic into its own class.
aholmes Mar 22, 2024
820e69a
Remove commented code.
aholmes Mar 22, 2024
60dd4be
Add some tests for BLAlembic.
aholmes Mar 23, 2024
e1fbb16
Fix error with injecting wrong Config type.
aholmes Mar 23, 2024
6ec57f9
Fix type errors.
aholmes Mar 23, 2024
992ed12
Ruff reformat caused by not using `--preview` in VSCode.
aholmes Mar 23, 2024
bd5c0a7
Remove bad variable name.
aholmes Mar 23, 2024
cc0d2ae
Add TODO comment for commented code.
aholmes Mar 23, 2024
fb9a13b
Fix incorrect string for exception in SQL schema setup.
aholmes Mar 23, 2024
b526c3c
Add newline.
aholmes Mar 27, 2024
9bd91a0
Add SAST scanner Bandit.
aholmes Mar 26, 2024
a95fca4
Fix name of SAST report CICD step.
aholmes Mar 26, 2024
51e3a75
Force CICD mode in Makefile during CICD.
aholmes Mar 26, 2024
e8069d8
Add CodeQL upload to SAST scan.
aholmes Mar 26, 2024
e5f7077
Fix problem with sometimes out-of-order make target executions.
aholmes Mar 26, 2024
24aec37
Fix problem caused by venv symlinks.
aholmes Mar 26, 2024
2e7d27b
Remove commented code.
aholmes Mar 27, 2024
6179357
Test whether using a consistent and specific Python version fixes ven…
aholmes Mar 27, 2024
3c0a7b8
Remove alteration of Python symlink in venv.
aholmes Mar 27, 2024
126b593
Update .github/workflows/CICD.yaml
aholmes Mar 27, 2024
47158f9
Show warning in PR if Bandit fails.
aholmes Mar 27, 2024
50e6e48
Change where pytest reports end up.
aholmes Mar 18, 2024
e0760c3
Fix misplaced comments.
aholmes Mar 18, 2024
e79ec51
Update Bandit report path to store under reports/
aholmes Mar 28, 2024
a418ed2
Merge branch 'main' into aholmes-add-alembic-support
aholmes Mar 28, 2024
822a913
Correct SARIF path in CICD.
aholmes Mar 28, 2024
522bb74
Add `BL_Python.all` package back to Makefile.
aholmes Mar 28, 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
9 changes: 3 additions & 6 deletions .github/workflows/CICD.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,7 @@ jobs:
with:
name: pytest-and-coverage-report
path: |
pytest.xml
cov.xml
.coverage
coverage/
reports/pytest/
retention-days: 1
if-no-files-found: error

Expand Down Expand Up @@ -219,14 +216,14 @@ jobs:
with:
name: bandit-sast-report
path: |
bandit.sarif
reports/bandit.sarif
retention-days: 1
if-no-files-found: error

- name: Upload bandit report to CodeQL
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: bandit.sarif
sarif_file: reports/bandit.sarif

Style:
name: Style and formatting
Expand Down
50 changes: 37 additions & 13 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,16 @@ GITHUB_REF ?= 00000000-0000-0000-0000-000000000000
# Can be overridden.
GITHUB_WORKSPACE ?= $(CURDIR)

# What repository to publish packages to.
# `testpypi` and `pypi` are valid values.
PYPI_REPO ?= testpypi

# The directory to write ephermal reports to,
# such as pytest coverage reports.
REPORTS_DIR ?= reports
BANDIT_REPORT := bandit.sarif
PYTEST_REPORT := pytest


# Can be overridden. This is used to change the prereqs
# of some supporting targets, like `format-ruff`.
Expand Down Expand Up @@ -70,8 +77,7 @@ PYPROJECT_FILES=./pyproject.toml $(wildcard src/*/pyproject.toml)
PACKAGE_PATHS=$(subst /pyproject.toml,,$(PYPROJECT_FILES))
PACKAGES=$(subst /pyproject.toml,,$(subst src/,BL_Python.,$(wildcard src/*/pyproject.toml)))

# Rather than duplicating BL_Python.all,
# just prereq it.
.PHONY: dev
dev : $(VENV) $(SETUP_DEPENDENCIES)
$(MAKE) _dev_build DEFAULT_TARGET=dev
_dev_configure : $(VENV) $(PYPROJECT_FILES)
Expand Down Expand Up @@ -108,6 +114,7 @@ _cicd_build : _cicd_configure

@$(REPORT_VENV_USAGE)

BL_Python.all: $(DEFAULT_TARGET)
$(PACKAGES) : BL_Python.%: src/%/pyproject.toml $(VENV) $(CONFIGURE_TARGET) $(PYPROJECT_FILES)
@if [ -d $(call package_to_dist,$*) ]; then
@echo "Package $@ is already built, skipping..."
Expand Down Expand Up @@ -165,6 +172,7 @@ format-ruff : $(VENV) $(BUILD_TARGET)

ruff format --preview --respect-gitignore

.PHONY: format format-ruff format-isort
format : $(VENV) $(BUILD_TARGET) format-isort format-ruff


Expand Down Expand Up @@ -201,49 +209,64 @@ test-bandit : $(VENV) $(BUILD_TARGET)
# while testing bandit.
-bandit -c pyproject.toml \
--format sarif \
--output $(BANDIT_REPORT) \
--output $(REPORTS_DIR)/$(BANDIT_REPORT) \
-r .

test-pytest : $(VENV) $(BUILD_TARGET)
$(ACTIVATE_VENV)

pytest $(PYTEST_FLAGS)
pytest $(PYTEST_FLAGS) \
&& PYTEST_EXIT_CODE=0 \
|| PYTEST_EXIT_CODE=$$?

-coverage html --data-file=$(REPORTS_DIR)/$(PYTEST_REPORT)/.coverage
-junit2html $(REPORTS_DIR)/$(PYTEST_REPORT)/pytest.xml $(REPORTS_DIR)/$(PYTEST_REPORT)/pytest.html

coverage html -d coverage
exit $$PYTEST_EXIT_CODE

.PHONY: test test-pytest test-bandit test-pyright test-ruff test-isort
_test : $(VENV) $(BUILD_TARGET) test-isort test-ruff test-pyright test-bandit test-pytest
test : CMD_PREFIX=@
test : $(VENV) $(BUILD_TARGET) clean-test test-isort test-ruff test-pyright test-bandit test-pytest
test : clean-test
$(MAKE) -j --keep-going _test


.PHONY: publish-all
# Publishing should use a real install, which `cicd` fulfills
publish-all : REWRITE_DEPENDENCIES=false
# Publishing should use a real install. Reset the build env.
publish-all : reset $(VENV)
$(ACTIVATE_VENV)

./publish_all.sh $(PYPI_REPO)


clean-build :
find . -type d \( \
find . -type d \
\( \
-path ./$(VENV) \
-o -path ./.git \
\) -prune -false \
-o \( \
-name build \
-o -name dist \
-o -name __pycache__ \
-o -name \*.egg-info \
-o -name .pytest-cache \
\) -prune -exec rm -rf {} \;

clean-test :
$(CMD_PREFIX)rm -rf cov.xml \
pytest.xml \
coverage \
.coverage \
$(BANDIT_REPORT)

$(CMD_PREFIX)rm -rf \
$(REPORTS_DIR)/$(PYTEST_REPORT) \
$(REPORTS_DIR)/$(BANDIT_REPORT)

.PHONY: clean clean-test clean-build
clean : clean-build clean-test
rm -rf $(VENV)

@echo '\nDeactivate your venv with `deactivate`'

.PHONY: remake
remake :
$(MAKE) clean
$(MAKE)
Expand All @@ -253,5 +276,6 @@ reset-check:
@echo -n "This will make destructive changes! Considering stashing changes first.\n"
@( read -p "Are you sure? [y/N]: " response && case "$$response" in [yY]) true;; *) false;; esac )

.PHONY: reset reset-check
reset : reset-check clean
git checkout -- $(PYPROJECT_FILES)
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ dev-dependencies = [
"pytest-mock",
"mock",
"pytest-cov ~= 4.1",
"coverage ~= 7.4",
"junit2html ~= 30.1",
"pyright ~= 1.1",
"isort ~= 5.13",
"ruff ~= 0.3",
Expand Down Expand Up @@ -138,6 +140,7 @@ reportUninitializedInstanceVariable = "information"
reportUnnecessaryTypeIgnoreComment = "information"
reportUnusedCallResult = "information"
reportMissingTypeStubs = "information"
reportWildcardImportFromLibrary = "warning"

[tool.pytest.ini_options]
pythonpath = [
Expand Down Expand Up @@ -173,9 +176,9 @@ addopts = [
# and
# https://github.com/microsoft/vscode-python/issues/21845
"--cov=.",
"--junitxml=pytest.xml",
"--junitxml=reports/pytest/pytest.xml",
"-o=junit_family=xunit2",
"--cov-report=xml:cov.xml",
"--cov-report=xml:reports/pytest/cov.xml",
"--cov-report=term-missing",
]

Expand All @@ -187,9 +190,11 @@ norecursedirs = "__pycache__ build .pytest_cache *.egg-info .venv .github-venv"
include_namespace_packages = true

[tool.coverage.html]
directory = "reports/pytest/coverage"
show_contexts = true

[tool.coverage.run]
data_file = "reports/pytest/.coverage"
dynamic_context = "test_function"
relative_files = true
omit = [
Expand Down
6 changes: 6 additions & 0 deletions reports/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# https://stackoverflow.com/a/932982

# Ignore everything in this directory
*
# Except this file
!.gitignore
34 changes: 34 additions & 0 deletions src/database/BL_Python/database/config.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,47 @@
from typing import Any

from BL_Python.programming.config import AbstractConfig
from pydantic import BaseModel
from pydantic.config import ConfigDict


class DatabaseConnectArgsConfig(BaseModel):
# allow any values, as this type is not
# specifically the type to be used elsewhere
model_config = ConfigDict(extra="allow")


class PostgreSQLDatabaseConnectArgsConfig(DatabaseConnectArgsConfig):
# ignore anything that DatabaseConnectArgsConfig
# allowed to be set, except for any other attributes
# of this class, which will end up assigned through
# the instatiation of the __init__ override of DatabaseConfig
model_config = ConfigDict(extra="ignore")

sslmode: str = ""
options: str = ""


class SQLiteDatabaseConnectArgsConfig(DatabaseConnectArgsConfig):
model_config = ConfigDict(extra="ignore")


class DatabaseConfig(BaseModel, AbstractConfig):
def __init__(self, **data: Any):
super().__init__(**data)

model_data = self.connect_args.model_dump() if self.connect_args else {}
if self.connection_string.startswith("sqlite://"):
self.connect_args = SQLiteDatabaseConnectArgsConfig(**model_data)
elif self.connection_string.startswith("postgresql://"):
self.connect_args = PostgreSQLDatabaseConnectArgsConfig(**model_data)

connection_string: str = "sqlite:///:memory:"
sqlalchemy_echo: bool = False
# the static field allows Pydantic to store
# values from a dictionary
connect_args: DatabaseConnectArgsConfig | None = None


class Config(BaseModel, AbstractConfig):
database: DatabaseConfig
115 changes: 0 additions & 115 deletions src/database/BL_Python/database/migrations/__init__.py
Original file line number Diff line number Diff line change
@@ -1,115 +0,0 @@
from typing import TYPE_CHECKING, List, Optional, Protocol, cast, final

from sqlalchemy.orm import DeclarativeMeta

MetaBaseType = Type[DeclarativeMeta]

if TYPE_CHECKING:
from typing import Dict, Protocol, Type, TypeVar, Union

from sqlalchemy.engine import Dialect

TBase = TypeVar("TBase")

class TableNameCallback(Protocol):
def __call__(
self,
dialect_schema: "Union[str, None]",
full_table_name: str,
base_table: str,
meta_base: MetaBaseType,
) -> None: ...

class Connection(Protocol):
dialect: Dialect

class Op(Protocol):
@staticmethod
def get_bind() -> Connection: ...


@final
class DialectHelper:
"""
Utilities to get database schema and table names
for different SQL dialects and database engines.

For example, PostgreSQL supports schemas. This means:
* get_dialect_schema(meta) returns a schema name, if there is one, e.g. "cap"
* get_full_table_name(table_name, meta) returns the schema name, followed by the table name, e.g. " cap.assay_plate "

SQLite does not support schemas. This means:
* get_dialect_schema(meta) returns None
* get_full_table_name(table_name, meta) returns the table name, with the schema name prepended to it, e.g. " 'cap.assay_plate' "
The key difference is that there is no schema, and the table name comes from the SQLite
engine instantiation, which prepends the "schema" to the table name.
"""

dialect: "Dialect"
dialect_supports_schemas: bool

def __init__(self, dialect: "Dialect"):
self.dialect = dialect
# right now we only care about SQLite and PSQL,
# so if the dialect is PSQL, then we consider the
# dialect to support schemas, otherwise it does not.
self.dialect_supports_schemas = dialect.name == "postgresql"

@staticmethod
def get_schema(meta: "MetaBaseType"):
table_args = cast(
Optional[dict[str, str]], getattr(meta, "__table_args__", None)
)
if table_args is None:
return None
return table_args.get("schema")

def get_dialect_schema(self, meta: "MetaBaseType"):
"""Get the database schema as a string, or None if the dialect does not support schemas."""
if not self.dialect_supports_schemas:
return None
return DialectHelper.get_schema(meta)

def get_full_table_name(self, table_name: str, meta: "MetaBaseType"):
"""
If the dialect supports schemas, then the table name does not have the schema prepended.
In dialects that don't support schemas, e.g., SQLite, the table name has the schema prepended.
This is because, when schemas are supported, the dialect automatically handles which schema
to use, while non-schema dialects do not reference any schemas.
"""
if self.get_dialect_schema(meta):
return table_name
else:
return f"{DialectHelper.get_schema(meta)}.{table_name}"

def get_timestamp_sql(self):
timestamp_default_sql = "now()"
if self.dialect.name == "sqlite":
timestamp_default_sql = "CURRENT_TIMESTAMP"
return timestamp_default_sql

@staticmethod
def iterate_table_names(
op: "Op",
schema_tables: "Dict[MetaBaseType, List[str]]",
table_name_callback: "TableNameCallback",
):
"""
Call `table_name_callback` once for every table in every Base.

op: The `op` object from Alembic.
schema_tables: A dictionary of the tables this call applies to for every Base.
table_name_callback: A callback executed for every table in `schema_tables`.
"""
dialect: Dialect = op.get_bind().dialect
schema = DialectHelper(dialect)
get_full_table_name = schema.get_full_table_name
get_dialect_schema = schema.get_dialect_schema

for meta_base, schema_base_tables in schema_tables.items():
dialect_schema = get_dialect_schema(meta_base)
for base_table in schema_base_tables:
full_table_name = get_full_table_name(base_table, meta_base)
table_name_callback(
dialect_schema, full_table_name, base_table, meta_base
)
Empty file.
Empty file.
31 changes: 31 additions & 0 deletions src/database/BL_Python/database/migrations/alembic/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import logging
from os import environ

# this is Alembic's main entry point
from .bl_alembic import BLAlembic


def bl_alembic(
argv: list[str] | None = None,
log_level: int | str | None = None,
allow_overwrite: bool | None = None,
) -> None:
"""
A method to support the `bl-alembic` command, which replaces `alembic.

:param list[str] | None argv: CLI arguments, defaults to None
:param int | str | None log_level: An integer log level to configure logging verbosity, defaults to None
"""
logging.basicConfig(level=logging.INFO)
if not log_level:
log_level = environ.get(BLAlembic.LOG_LEVEL_NAME)
log_level = int(log_level) if log_level else logging.INFO

logger = logging.getLogger()
logger.setLevel(log_level)

BLAlembic(argv, logger).run()


if __name__ == "__main__":
bl_alembic()
Loading
Loading