|
6 | 6 | from datetime import datetime
|
7 | 7 | from os import listdir, path
|
8 | 8 | from os.path import isfile, join
|
| 9 | +from types import ModuleType |
| 10 | +from typing import List, Union |
9 | 11 |
|
10 | 12 | from sqlalchemy import DateTime, String
|
11 | 13 | 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 |
13 | 15 |
|
14 | 16 | from alembic import context
|
15 | 17 | from common.bootstrap import application_init
|
@@ -81,61 +83,234 @@ class FixtureMigration(declarative_base):
|
81 | 83 | # ... etc.
|
82 | 84 |
|
83 | 85 |
|
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: |
94 | 87 | alembic_path = path.dirname(path.realpath(__file__))
|
95 | 88 | 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, |
108 | 212 | )
|
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" |
116 | 260 | )
|
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" |
120 | 265 | )
|
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" |
129 | 291 | )
|
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 |
135 | 303 | )
|
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 | + ) |
139 | 314 |
|
140 | 315 |
|
141 | 316 | def run_migrations_offline() -> None:
|
@@ -225,14 +400,15 @@ def migration_callable(*args, **kwargs):
|
225 | 400 | return do_run_migration(*args, name=name, **kwargs)
|
226 | 401 |
|
227 | 402 | await rec["connection"].run_sync(migration_callable)
|
228 |
| - await a_migrate_fixtures( |
| 403 | + await FixtureHandler.a_migrate_fixtures( |
229 | 404 | bind_name=name, session=async_sessionmaker(bind=rec["connection"])
|
230 | 405 | )
|
231 | 406 |
|
232 | 407 | else:
|
233 | 408 | 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 | + ) |
236 | 412 |
|
237 | 413 | if USE_TWOPHASE:
|
238 | 414 | for rec in engines.values():
|
|
0 commit comments