Skip to content

Commit bc258e8

Browse files
Allow db_url parameter to create_database_migration_numbered_style
1 parent 0768fe3 commit bc258e8

3 files changed

Lines changed: 112 additions & 84 deletions

File tree

cardinal_pythonlib/sqlalchemy/alembic_func.py

Lines changed: 107 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,25 @@
2626
2727
"""
2828

29+
from configparser import ConfigParser
30+
import logging
2931
import os
3032
import re
3133
import subprocess
34+
from tempfile import NamedTemporaryFile
3235
from typing import Tuple
3336

34-
from alembic.config import Config
37+
from alembic.config import Config as AlembicConfig
3538
from alembic.util.exc import CommandError
3639
from alembic.runtime.migration import MigrationContext
3740
from alembic.runtime.environment import EnvironmentContext
3841
from alembic.script import ScriptDirectory
3942
from sqlalchemy.engine import create_engine
4043

4144
from 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"""
378381
Generating 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

406431
def stamp_allowing_unusual_version_table(
407-
config: Config,
432+
config: AlembicConfig,
408433
revision: str,
409434
sql: bool = False,
410435
tag: str = None,

cardinal_pythonlib/version_string.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,5 @@
3131
3232
"""
3333

34-
VERSION_STRING = "2.0.2"
34+
VERSION_STRING = "2.0.3"
3535
# Use semantic versioning: https://semver.org/

docs/source/changelog.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,10 @@ Quick links:
873873

874874
- Improve ability of Alembic support code to take a database URL.
875875

876-
**2.0.3**
876+
**2.0.3 (2023-03-11)**
877877

878878
- Reinstate BIT and similar datatypes in the list of valid datatypes. Broken
879879
since v2.0.0.
880+
881+
- Allow ``db_url`` parameter to
882+
``cardinal_pythonlib.sqlalchemy.alembic_func.create_database_migration_numbered_style``.

0 commit comments

Comments
 (0)