Skip to content

Commit 249dd16

Browse files
committed
Refactor fixture migration logic into FixtureHandler class
This refactor centralizes and organizes the fixture migration logic within the new `FixtureHandler` class. It improves modularity, adds clarity with docstrings, and ensures code reuse for both synchronous and asynchronous contexts. Additionally, it updates the linting configuration to allow long lines in `env.py`.
1 parent 0f5da8f commit 249dd16

File tree

2 files changed

+229
-52
lines changed

2 files changed

+229
-52
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,4 @@ ignore = [
151151
[tool.ruff.lint.per-file-ignores]
152152
"__init__.py" = ["F401"] # Ignore unused imports on init files
153153
"tests/**/*.py" = ["S101"] # Allow assert usage on tests
154+
"src/alembic/env.py" = ["E501"] # Allow long lines

src/alembic/env.py

Lines changed: 228 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from datetime import datetime
77
from os import listdir, path
88
from os.path import isfile, join
9+
from types import ModuleType
10+
from typing import List, Union
911

1012
from sqlalchemy import DateTime, String
1113
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
12-
from sqlalchemy.orm import Mapped, mapped_column
14+
from sqlalchemy.orm import Mapped, Session, mapped_column, sessionmaker
1315

1416
from alembic import context
1517
from common.bootstrap import application_init
@@ -81,61 +83,234 @@ class FixtureMigration(declarative_base):
8183
# ... etc.
8284

8385

84-
def calculate_signature(file_path):
85-
hasher = hashlib.sha256()
86-
with open(file_path, "rb") as file:
87-
hasher.update(file.read())
88-
return hasher.hexdigest()
89-
90-
91-
async def a_migrate_fixtures(
92-
bind_name: str, session: async_sessionmaker[AsyncSession]
93-
) -> str:
86+
class FixtureHandler:
9487
alembic_path = path.dirname(path.realpath(__file__))
9588
fixtures_path = alembic_path + "/fixtures"
96-
sys.path.append(alembic_path)
97-
module_names = [
98-
f[:-3]
99-
for f in listdir(fixtures_path)
100-
if isfile(join(fixtures_path, f)) and f.endswith(".py") and f != "__init__.py"
101-
]
102-
async with session() as session:
103-
for module_name in module_names:
104-
logging.debug(f"Creating {module_name} fixtures for {bind_name}")
105-
m = importlib.import_module(f"fixtures.{module_name}")
106-
fixture_migration = await session.get(
107-
fixture_migration_models[bind_name], (bind_name, f"{module_name}.py")
89+
logger = logging.getLogger("alembic.runtime.fixtures")
90+
91+
@classmethod
92+
def _calculate_signature(cls, fixture_module: ModuleType) -> str:
93+
"""
94+
Calculate the SHA-256 signature for a fixture module's corresponding file.
95+
96+
This method computes a unique hash for the content of a specific Python source
97+
file associated with a given fixture module. The hash is calculated using the
98+
SHA-256 algorithm, ensuring a consistent and secure checksum.
99+
100+
Args:
101+
fixture_module (ModuleType): The module whose associated file's signature
102+
needs to be calculated.
103+
104+
Returns:
105+
str: The hexadecimal SHA-256 hash of the file content.
106+
"""
107+
file_path = f"{cls.fixtures_path}/{fixture_module.__name__[9:]}.py"
108+
hasher = hashlib.sha256()
109+
with open(file_path, "rb") as file:
110+
hasher.update(file.read())
111+
return hasher.hexdigest()
112+
113+
@classmethod
114+
def _get_fixture_modules(cls) -> List[ModuleType]:
115+
"""
116+
This private class method is responsible for retrieving modules from the fixtures
117+
directory defined by the class attributes. It dynamically imports Python modules
118+
located in the specified fixtures directory and filters out non-Python files
119+
or the __init__.py file. It adds the Alembic path to the system path to ensure
120+
successful imports.
121+
122+
Parameters
123+
----------
124+
None
125+
126+
Returns
127+
-------
128+
List[ModuleType]
129+
A list of imported module objects dynamically loaded from the fixtures
130+
directory.
131+
"""
132+
sys.path.append(cls.alembic_path)
133+
return [
134+
importlib.import_module(f"fixtures.{f[:-3]}")
135+
for f in listdir(cls.fixtures_path)
136+
if isfile(join(cls.fixtures_path, f))
137+
and f.endswith(".py")
138+
and f != "__init__.py"
139+
]
140+
141+
@classmethod
142+
def _fixture_already_migrated(cls, fixture_migration, signature) -> bool:
143+
"""
144+
Determines if a fixture has already been migrated based on the given fixture
145+
migration and its signature.
146+
147+
The method examines the provided fixture migration data and its signature to
148+
decide whether the fixture has already been processed. If the signatures do not
149+
match, a warning is logged to indicate potential modifications. Otherwise, a debug
150+
message is logged to confirm prior processing. The return value indicates whether
151+
the fixture should be skipped.
152+
153+
Args:
154+
fixture_migration (FixtureMigration | None): An object representing the migration
155+
details of a fixture. Can be None.
156+
signature (str): A unique string indicating the signature of the current fixture.
157+
158+
Returns:
159+
bool: True if the fixture has already been migrated and should not be processed
160+
again; False otherwise.
161+
"""
162+
if fixture_migration:
163+
if signature != fixture_migration.signature:
164+
cls.logger.warning(
165+
f"Signature mismatch for `{fixture_migration.filename}` fixture."
166+
f" The file has been already processed but has been modified"
167+
f" since then. It will not be processed again."
168+
)
169+
else:
170+
cls.logger.debug(
171+
f"`{fixture_migration.filename}` fixtures already processed for `{fixture_migration.bind}` bind"
172+
)
173+
return True
174+
return False
175+
176+
@classmethod
177+
def _add_fixture_data_to_session(
178+
cls,
179+
bind_name: str,
180+
fixture_module: ModuleType,
181+
session: Union[Session, AsyncSession],
182+
signature: str,
183+
):
184+
"""
185+
Adds fixture data and migration model to the given session.
186+
187+
This method interacts with the database session to add predefined fixture data
188+
and creates a corresponding migration model for tracking purposes. The fixture
189+
data is retrieved from the specified fixture module, based on the provided bind
190+
name. The migration model contains metadata about the fixture module and its
191+
signature.
192+
193+
Args:
194+
bind_name (str): The binding name used to fetch fixture data from the
195+
fixture module.
196+
fixture_module (ModuleType): The module containing fixture data and fixture
197+
metadata definitions.
198+
session (Union[Session, AsyncSession]): A database session where fixture
199+
data and migration models are added.
200+
signature (str): A unique signature representing the state of the fixture
201+
module.
202+
203+
Returns:
204+
None
205+
"""
206+
session.add_all(fixture_module.fixtures().get(bind_name, []))
207+
session.add(
208+
fixture_migration_models[bind_name](
209+
bind=bind_name,
210+
filename=f"{fixture_module.__name__}",
211+
signature=signature,
108212
)
109-
signature = calculate_signature(f"{fixtures_path}/{module_name}.py")
110-
if fixture_migration:
111-
if signature != fixture_migration.signature:
112-
logging.warning(
113-
f"Signature mismatch for {fixture_migration.filename} fixture."
114-
f" The file has been already processed but has been modified"
115-
f" since then. It will not be processed again."
213+
)
214+
215+
@classmethod
216+
async def a_migrate_fixtures(
217+
cls, bind_name: str, session: async_sessionmaker[AsyncSession]
218+
):
219+
"""
220+
Perform asynchronous migration of fixture data modules for a specific database bind.
221+
222+
This method iterates over fixture data modules, calculates their signatures, and determines
223+
whether fixtures have already been migrated for a specific database bind. If not, it migrates
224+
them by adding the data to the session and commits the changes. If an error occurs during
225+
the commit, it rolls back the session. Logs are produced at each significant step.
226+
227+
Args:
228+
bind_name: The name of the database bind for which the fixtures are being migrated.
229+
session: An instance of `async_sessionmaker[AsyncSession]` used for interacting with
230+
the database.
231+
232+
Raises:
233+
Exception: If a commit to the database fails.
234+
235+
Returns:
236+
None
237+
"""
238+
modules = cls._get_fixture_modules()
239+
async with session() as session:
240+
for fixture_module in modules:
241+
cls.logger.debug(
242+
f"Creating `{fixture_module.__name__}` fixtures for `{bind_name}` bind"
243+
)
244+
fixture_migration = await session.get(
245+
fixture_migration_models[bind_name],
246+
(bind_name, f"{fixture_module.__name__}"),
247+
)
248+
249+
signature = cls._calculate_signature(fixture_module)
250+
if cls._fixture_already_migrated(fixture_migration, signature):
251+
continue
252+
253+
cls._add_fixture_data_to_session(
254+
bind_name, fixture_module, session, signature
255+
)
256+
try:
257+
await session.commit()
258+
cls.logger.info(
259+
f"`{fixture_module.__name__}` fixtures correctly created for `{bind_name}` bind"
116260
)
117-
else:
118-
logging.debug(
119-
f"{module_name} fixtures already processed for {bind_name}"
261+
except Exception:
262+
await session.rollback()
263+
cls.logger.error(
264+
f"`{fixture_module.__name__}` fixtures failed to apply to `{bind_name}` bind"
120265
)
121-
continue
122-
123-
session.add_all(m.fixtures().get(bind_name, []))
124-
session.add(
125-
fixture_migration_models[bind_name](
126-
bind=bind_name,
127-
filename=f"{module_name}.py",
128-
signature=signature,
266+
267+
@classmethod
268+
def migrate_fixtures(cls, bind_name: str, session: sessionmaker[Session]):
269+
"""
270+
Migrate fixture data for a specified bind to the database session. This process involves identifying
271+
fixture modules, calculating their signatures, checking if a module's data is already migrated, and
272+
applying the fixture data if necessary. The migration process is committed to the session or rolled back
273+
in case of failure.
274+
275+
Parameters:
276+
cls: Type[CurrentClassType]
277+
The class on which the method is being called.
278+
bind_name: str
279+
The name of the database bind to which the fixtures are being migrated.
280+
session: sessionmaker[Session]
281+
The SQLAlchemy session maker instance used for initiating the session.
282+
283+
Raises:
284+
None explicitly raised but may propagate exceptions during database operations.
285+
"""
286+
modules = cls._get_fixture_modules()
287+
with session() as session:
288+
for fixture_module in modules:
289+
cls.logger.debug(
290+
f"Creating `{fixture_module.__name__}` fixtures for `{bind_name}` bind"
129291
)
130-
)
131-
try:
132-
await session.commit()
133-
logging.info(
134-
f"{module_name} fixtures correctly created for {bind_name}"
292+
fixture_migration = session.get(
293+
fixture_migration_models[bind_name],
294+
(bind_name, f"{fixture_module.__name__}"),
295+
)
296+
297+
signature = cls._calculate_signature(fixture_module)
298+
if cls._fixture_already_migrated(fixture_migration, signature):
299+
continue
300+
301+
cls._add_fixture_data_to_session(
302+
bind_name, fixture_module, session, signature
135303
)
136-
except Exception:
137-
await session.rollback()
138-
logging.error(f"{module_name} fixtures failed to apply to {bind_name}")
304+
try:
305+
session.commit()
306+
cls.logger.info(
307+
f"`{fixture_module.__name__}` fixtures correctly created for `{bind_name}` bind"
308+
)
309+
except Exception:
310+
session.rollback()
311+
cls.logger.error(
312+
f"`{fixture_module.__name__}` fixtures failed to apply to `{bind_name}` bind"
313+
)
139314

140315

141316
def run_migrations_offline() -> None:
@@ -225,14 +400,15 @@ def migration_callable(*args, **kwargs):
225400
return do_run_migration(*args, name=name, **kwargs)
226401

227402
await rec["connection"].run_sync(migration_callable)
228-
await a_migrate_fixtures(
403+
await FixtureHandler.a_migrate_fixtures(
229404
bind_name=name, session=async_sessionmaker(bind=rec["connection"])
230405
)
231406

232407
else:
233408
do_run_migration(rec["connection"], name)
234-
# Session = sessionmaker(bind=rec["connection"])
235-
# session = Session()
409+
FixtureHandler.migrate_fixtures(
410+
bind_name=name, session=sessionmaker(bind=rec["connection"])
411+
)
236412

237413
if USE_TWOPHASE:
238414
for rec in engines.values():

0 commit comments

Comments
 (0)