2626
2727"""
2828
29+ from configparser import ConfigParser
30+ import logging
2931import os
3032import re
3133import subprocess
34+ from tempfile import NamedTemporaryFile
3235from typing import Tuple
3336
34- from alembic .config import Config
37+ from alembic .config import Config as AlembicConfig
3538from alembic .util .exc import CommandError
3639from alembic .runtime .migration import MigrationContext
3740from alembic .runtime .environment import EnvironmentContext
3841from alembic .script import ScriptDirectory
3942from sqlalchemy .engine import create_engine
4043
4144from cardinal_pythonlib .fileops import preserve_cwd
42- from cardinal_pythonlib .logs import get_brace_style_log_with_null_handler
45+ from cardinal_pythonlib .sqlalchemy . session import get_safe_url_from_url
4346
44- log = get_brace_style_log_with_null_handler (__name__ )
47+ log = logging . getLogger (__name__ )
4548
4649
4750# =============================================================================
@@ -80,7 +83,7 @@ def get_head_revision_from_alembic(
8083
8184 Arguments:
8285 alembic_config_filename:
83- config filename
86+ config filename (usually a full path to an alembic.ini file)
8487 alembic_base_dir:
8588 directory to start in, so relative paths in the config file work.
8689 version_table:
@@ -89,9 +92,9 @@ def get_head_revision_from_alembic(
8992 if alembic_base_dir is None :
9093 alembic_base_dir = os .path .dirname (alembic_config_filename )
9194 os .chdir (alembic_base_dir ) # so the directory in the config file works
92- config = Config (alembic_config_filename )
93- script = ScriptDirectory .from_config (config )
94- with EnvironmentContext (config , script , version_table = version_table ):
95+ alembic_cfg = AlembicConfig (alembic_config_filename )
96+ script = ScriptDirectory .from_config (alembic_cfg )
97+ with EnvironmentContext (alembic_cfg , script , version_table = version_table ):
9598 return script .get_current_head ()
9699
97100
@@ -123,25 +126,28 @@ def get_current_and_head_revision(
123126 :func:`get_current_revision` and :func:`get_head_revision_from_alembic`.
124127
125128 Arguments:
126- database_url: SQLAlchemy URL for the database
127- alembic_config_filename: config filename
128- alembic_base_dir: directory to start in, so relative paths in the
129- config file work.
130- version_table: table name for Alembic versions
129+ database_url:
130+ SQLAlchemy URL for the database
131+ alembic_config_filename:
132+ config filename (usually a full path to an alembic.ini file)
133+ alembic_base_dir:
134+ directory to start in, so relative paths in the config file work.
135+ version_table:
136+ table name for Alembic versions
131137 """
132138 # Where we are
133139 head_revision = get_head_revision_from_alembic (
134140 alembic_config_filename = alembic_config_filename ,
135141 alembic_base_dir = alembic_base_dir ,
136142 version_table = version_table ,
137143 )
138- log .debug ("Intended database version: {}" , head_revision )
144+ log .debug (f "Intended database version: { head_revision } " )
139145
140146 # Where we want to be
141147 current_revision = get_current_revision (
142148 database_url = database_url , version_table = version_table
143149 )
144- log .debug ("Current database version: {}" , current_revision )
150+ log .debug (f "Current database version: { current_revision } " )
145151
146152 # Are we where we want to be?
147153 return current_revision , head_revision
@@ -165,49 +171,43 @@ def upgrade_database(
165171
166172 Arguments:
167173 alembic_config_filename:
168- config filename
169-
174+ config filename (usually a full path to an alembic.ini file)
170175 db_url:
171176 Optional database URL to use, by way of override.
172-
173177 alembic_base_dir:
174178 directory to start in, so relative paths in the config file work
175-
176179 starting_revision:
177180 revision to start at (typically ``None`` to ask the database)
178-
179181 destination_revision:
180182 revision to aim for (typically ``"head"`` to migrate to the latest
181183 structure)
182-
183- version_table: table name for Alembic versions
184-
184+ version_table:
185+ table name for Alembic versions
185186 as_sql:
186187 run in "offline" mode: print the migration SQL, rather than
187188 modifying the database. See
188189 https://alembic.zzzcomputing.com/en/latest/offline.html
189-
190190 """
191191
192192 if alembic_base_dir is None :
193193 alembic_base_dir = os .path .dirname (alembic_config_filename )
194194 os .chdir (alembic_base_dir ) # so the directory in the config file works
195- config = Config (alembic_config_filename )
195+ alembic_cfg = AlembicConfig (alembic_config_filename )
196196 if db_url :
197- config .set_main_option ("sqlalchemy.url" , db_url )
198- script = ScriptDirectory .from_config (config )
197+ alembic_cfg .set_main_option ("sqlalchemy.url" , db_url )
198+ script = ScriptDirectory .from_config (alembic_cfg )
199199
200200 # noinspection PyUnusedLocal,PyProtectedMember
201201 def upgrade (rev , context ):
202202 return script ._upgrade_revs (destination_revision , rev )
203203
204204 log .info (
205- "Upgrading database to revision {!r} using Alembic" ,
206- destination_revision ,
205+ f "Upgrading database to revision { destination_revision !r} "
206+ f"using Alembic"
207207 )
208208
209209 with EnvironmentContext (
210- config ,
210+ alembic_cfg ,
211211 script ,
212212 fn = upgrade ,
213213 as_sql = as_sql ,
@@ -240,48 +240,42 @@ def downgrade_database(
240240
241241 Arguments:
242242 alembic_config_filename:
243- config filename
244-
243+ config filename (usually a full path to an alembic.ini file)
245244 db_url:
246245 Optional database URL to use, by way of override.
247-
248246 alembic_base_dir:
249247 directory to start in, so relative paths in the config file work
250-
251248 starting_revision:
252249 revision to start at (typically ``None`` to ask the database)
253-
254250 destination_revision:
255251 revision to aim for
256-
257- version_table: table name for Alembic versions
258-
252+ version_table:
253+ table name for Alembic versions
259254 as_sql:
260255 run in "offline" mode: print the migration SQL, rather than
261256 modifying the database. See
262257 https://alembic.zzzcomputing.com/en/latest/offline.html
263-
264258 """
265259
266260 if alembic_base_dir is None :
267261 alembic_base_dir = os .path .dirname (alembic_config_filename )
268262 os .chdir (alembic_base_dir ) # so the directory in the config file works
269- config = Config (alembic_config_filename )
263+ alembic_cfg = AlembicConfig (alembic_config_filename )
270264 if db_url :
271- config .set_main_option ("sqlalchemy.url" , db_url )
272- script = ScriptDirectory .from_config (config )
265+ alembic_cfg .set_main_option ("sqlalchemy.url" , db_url )
266+ script = ScriptDirectory .from_config (alembic_cfg )
273267
274268 # noinspection PyUnusedLocal,PyProtectedMember
275269 def downgrade (rev , context ):
276270 return script ._downgrade_revs (destination_revision , rev )
277271
278272 log .info (
279- "Downgrading database to revision {!r} using Alembic" ,
280- destination_revision ,
273+ f "Downgrading database to revision { destination_revision !r} "
274+ f"using Alembic"
281275 )
282276
283277 with EnvironmentContext (
284- config ,
278+ alembic_cfg ,
285279 script ,
286280 fn = downgrade ,
287281 as_sql = as_sql ,
@@ -301,6 +295,7 @@ def create_database_migration_numbered_style(
301295 alembic_versions_dir : str ,
302296 message : str ,
303297 n_sequence_chars : int = 4 ,
298+ db_url : str = None ,
304299) -> None :
305300 """
306301 Create a new Alembic migration script.
@@ -331,35 +326,43 @@ def create_database_migration_numbered_style(
331326
332327 See https://alembic.zzzcomputing.com/en/latest/autogenerate.html.
333328
334- Regarding filenames: the default ``n_sequence_chars`` of 4 is like Django
335- and gives files with names like
329+ Regarding filenames: the default ``n_sequence_chars`` of 4 is like Django
330+ and gives files with names like
336331
337- .. code-block:: none
332+ .. code-block:: none
338333
339- 0001_x.py, 0002_y.py, ...
334+ 0001_x.py, 0002_y.py, ...
340335
341- NOTE THAT TO USE A NON-STANDARD ALEMBIC VERSION TABLE, YOU MUST SPECIFY
342- THAT IN YOUR ``env.py`` (see e.g. CamCOPS).
336+ NOTE THAT TO USE A NON-STANDARD ALEMBIC VERSION TABLE, YOU MUST SPECIFY
337+ THAT IN YOUR ``env.py`` (see e.g. CamCOPS).
343338
344- Args:
345- alembic_ini_file: filename of Alembic ``alembic.ini`` file
346- alembic_versions_dir: directory in which you keep your Python scripts,
347- one per Alembic revision
348- message: message to be associated with this revision
349- n_sequence_chars: number of numerical sequence characters to use in the
350- filename/revision (see above).
339+ Args:
340+ alembic_ini_file:
341+ filename (full path) of Alembic ``alembic.ini`` file
342+ alembic_versions_dir:
343+ directory in which you keep your Python scripts, one per Alembic
344+ revision
345+ message:
346+ message to be associated with this revision
347+ n_sequence_chars:
348+ number of numerical sequence characters to use in the
349+ filename/revision (see above).
350+ db_url:
351+ Optional database URL to use, by way of override. We achieve this
352+ via a temporary config file; not ideal.
351353 """ # noqa: E501
352- file_regex = r"\d{" + str (n_sequence_chars ) + r"}_\S*\.py$"
353354
355+ # Calculate current_seq_str, new_seq_str:
356+ file_regex = r"\d{" + str (n_sequence_chars ) + r"}_\S*\.py$"
354357 _ , _ , existing_version_filenames = next (
355358 os .walk (alembic_versions_dir ), (None , None , [])
356359 )
357360 existing_version_filenames = [
358361 x for x in existing_version_filenames if re .match (file_regex , x )
359362 ]
360363 log .debug (
361- "Existing Alembic version script filenames: {!r}" ,
362- existing_version_filenames ,
364+ f "Existing Alembic version script filenames: "
365+ f" { existing_version_filenames !r } "
363366 )
364367 current_seq_strs = [
365368 x [:n_sequence_chars ] for x in existing_version_filenames
@@ -374,37 +377,59 @@ def create_database_migration_numbered_style(
374377 new_seq_str = str (new_seq_no ).zfill (n_sequence_chars )
375378
376379 log .info (
377- """
380+ f """
378381Generating new revision with Alembic...
379- Last revision was: {}
380- New revision will be: {}
382+ Last revision was: { current_seq_str }
383+ New revision will be: { new_seq_str }
381384 [If it fails with "Can't locate revision identified by...", you might need
382385 to DROP the Alembic version table (by default named 'alembic_version', but
383- you may have elected to change that in your env.py.]
384- """ ,
385- current_seq_str ,
386- new_seq_str ,
386+ you may have elected to change that in your env.py).]
387+ """
387388 )
388389
389390 alembic_ini_dir = os .path .dirname (alembic_ini_file )
390- os .chdir (alembic_ini_dir )
391- cmdargs = [
392- "alembic" ,
393- "-c" ,
394- alembic_ini_file ,
395- "revision" ,
396- "--autogenerate" ,
397- "-m" ,
398- message ,
399- "--rev-id" ,
400- new_seq_str ,
401- ]
402- log .info ("From directory {!r}, calling: {!r}" , alembic_ini_dir , cmdargs )
403- subprocess .call (cmdargs )
391+
392+ def _call_alembic (_alembic_ini_filename ):
393+ os .chdir (alembic_ini_dir )
394+ cmdargs = [
395+ "alembic" ,
396+ "-c" ,
397+ _alembic_ini_filename ,
398+ "revision" ,
399+ "--autogenerate" ,
400+ "-m" ,
401+ message ,
402+ "--rev-id" ,
403+ new_seq_str ,
404+ ]
405+ log .info (f"From directory { alembic_ini_dir !r} , calling: { cmdargs !r} " )
406+ subprocess .call (cmdargs )
407+
408+ if db_url :
409+ # Override the database URL. This is a bit ugly, because it's not
410+ # obvious how to pass a URL directly to the "alembic revision" command.
411+ # I don't think there's an API for that, and "alembic revision --help"
412+ # doesn't show any URL options. So, a temporary config:
413+ safe_url = get_safe_url_from_url (db_url )
414+ alembic_cfg = AlembicConfig (alembic_ini_file )
415+ alembic_cfg .set_main_option ("sqlalchemy.url" , db_url )
416+ with NamedTemporaryFile (
417+ mode = "w+t" , dir = alembic_ini_dir , suffix = ".ini"
418+ ) as tmpfile :
419+ log .info (
420+ f"Overriding database URL with { safe_url } , "
421+ f"via temporary file { tmpfile .name } "
422+ )
423+ cfgparser : ConfigParser = alembic_cfg .file_config
424+ cfgparser .write (tmpfile )
425+ tmpfile .flush ()
426+ _call_alembic (tmpfile .name )
427+ else :
428+ _call_alembic (alembic_ini_file )
404429
405430
406431def stamp_allowing_unusual_version_table (
407- config : Config ,
432+ config : AlembicConfig ,
408433 revision : str ,
409434 sql : bool = False ,
410435 tag : str = None ,
0 commit comments