alembic 1.15.2__py3-none-any.whl → 1.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. alembic/__init__.py +1 -1
  2. alembic/autogenerate/compare.py +60 -7
  3. alembic/autogenerate/render.py +25 -4
  4. alembic/command.py +112 -37
  5. alembic/config.py +574 -222
  6. alembic/ddl/base.py +31 -7
  7. alembic/ddl/impl.py +23 -5
  8. alembic/ddl/mssql.py +3 -1
  9. alembic/ddl/mysql.py +8 -4
  10. alembic/ddl/postgresql.py +6 -2
  11. alembic/ddl/sqlite.py +1 -1
  12. alembic/op.pyi +24 -5
  13. alembic/operations/base.py +18 -3
  14. alembic/operations/ops.py +49 -8
  15. alembic/operations/toimpl.py +20 -3
  16. alembic/script/base.py +123 -136
  17. alembic/script/revision.py +1 -1
  18. alembic/script/write_hooks.py +20 -21
  19. alembic/templates/async/alembic.ini.mako +40 -16
  20. alembic/templates/generic/alembic.ini.mako +39 -17
  21. alembic/templates/multidb/alembic.ini.mako +42 -17
  22. alembic/templates/pyproject/README +1 -0
  23. alembic/templates/pyproject/alembic.ini.mako +44 -0
  24. alembic/templates/pyproject/env.py +78 -0
  25. alembic/templates/pyproject/pyproject.toml.mako +76 -0
  26. alembic/templates/pyproject/script.py.mako +28 -0
  27. alembic/testing/__init__.py +2 -0
  28. alembic/testing/assertions.py +4 -0
  29. alembic/testing/env.py +56 -1
  30. alembic/testing/fixtures.py +28 -1
  31. alembic/testing/suite/_autogen_fixtures.py +113 -0
  32. alembic/util/__init__.py +1 -0
  33. alembic/util/compat.py +56 -0
  34. alembic/util/messaging.py +4 -0
  35. alembic/util/pyfiles.py +56 -19
  36. {alembic-1.15.2.dist-info → alembic-1.16.0.dist-info}/METADATA +3 -3
  37. {alembic-1.15.2.dist-info → alembic-1.16.0.dist-info}/RECORD +41 -36
  38. {alembic-1.15.2.dist-info → alembic-1.16.0.dist-info}/WHEEL +1 -1
  39. {alembic-1.15.2.dist-info → alembic-1.16.0.dist-info}/entry_points.txt +0 -0
  40. {alembic-1.15.2.dist-info → alembic-1.16.0.dist-info}/licenses/LICENSE +0 -0
  41. {alembic-1.15.2.dist-info → alembic-1.16.0.dist-info}/top_level.txt +0 -0
alembic/config.py CHANGED
@@ -5,6 +5,8 @@ from argparse import Namespace
5
5
  from configparser import ConfigParser
6
6
  import inspect
7
7
  import os
8
+ from pathlib import Path
9
+ import re
8
10
  import sys
9
11
  from typing import Any
10
12
  from typing import cast
@@ -12,6 +14,7 @@ from typing import Dict
12
14
  from typing import Mapping
13
15
  from typing import Optional
14
16
  from typing import overload
17
+ from typing import Protocol
15
18
  from typing import Sequence
16
19
  from typing import TextIO
17
20
  from typing import Union
@@ -22,6 +25,7 @@ from . import __version__
22
25
  from . import command
23
26
  from . import util
24
27
  from .util import compat
28
+ from .util.pyfiles import _preserving_path_as_str
25
29
 
26
30
 
27
31
  class Config:
@@ -81,12 +85,13 @@ class Config:
81
85
  Defaults to ``sys.stdout``.
82
86
 
83
87
  :param config_args: A dictionary of keys and values that will be used
84
- for substitution in the alembic config file. The dictionary as given
85
- is **copied** to a new one, stored locally as the attribute
86
- ``.config_args``. When the :attr:`.Config.file_config` attribute is
87
- first invoked, the replacement variable ``here`` will be added to this
88
- dictionary before the dictionary is passed to ``ConfigParser()``
89
- to parse the .ini file.
88
+ for substitution in the alembic config file, as well as the pyproject.toml
89
+ file, depending on which / both are used. The dictionary as given is
90
+ **copied** to two new, independent dictionaries, stored locally under the
91
+ attributes ``.config_args`` and ``.toml_args``. Both of these
92
+ dictionaries will also be populated with the replacement variable
93
+ ``%(here)s``, which refers to the location of the .ini and/or .toml file
94
+ as appropriate.
90
95
 
91
96
  :param attributes: optional dictionary of arbitrary Python keys/values,
92
97
  which will be populated into the :attr:`.Config.attributes` dictionary.
@@ -100,6 +105,7 @@ class Config:
100
105
  def __init__(
101
106
  self,
102
107
  file_: Union[str, os.PathLike[str], None] = None,
108
+ toml_file: Union[str, os.PathLike[str], None] = None,
103
109
  ini_section: str = "alembic",
104
110
  output_buffer: Optional[TextIO] = None,
105
111
  stdout: TextIO = sys.stdout,
@@ -108,12 +114,18 @@ class Config:
108
114
  attributes: Optional[Dict[str, Any]] = None,
109
115
  ) -> None:
110
116
  """Construct a new :class:`.Config`"""
111
- self.config_file_name = file_
117
+ self.config_file_name = (
118
+ _preserving_path_as_str(file_) if file_ else None
119
+ )
120
+ self.toml_file_name = (
121
+ _preserving_path_as_str(toml_file) if toml_file else None
122
+ )
112
123
  self.config_ini_section = ini_section
113
124
  self.output_buffer = output_buffer
114
125
  self.stdout = stdout
115
126
  self.cmd_opts = cmd_opts
116
127
  self.config_args = dict(config_args)
128
+ self.toml_args = dict(config_args)
117
129
  if attributes:
118
130
  self.attributes.update(attributes)
119
131
 
@@ -129,9 +141,28 @@ class Config:
129
141
 
130
142
  """
131
143
 
132
- config_file_name: Union[str, os.PathLike[str], None] = None
144
+ config_file_name: Optional[str] = None
133
145
  """Filesystem path to the .ini file in use."""
134
146
 
147
+ toml_file_name: Optional[str] = None
148
+ """Filesystem path to the pyproject.toml file in use.
149
+
150
+ .. versionadded:: 1.16.0
151
+
152
+ """
153
+
154
+ @property
155
+ def _config_file_path(self) -> Optional[Path]:
156
+ if self.config_file_name is None:
157
+ return None
158
+ return Path(self.config_file_name)
159
+
160
+ @property
161
+ def _toml_file_path(self) -> Optional[Path]:
162
+ if self.toml_file_name is None:
163
+ return None
164
+ return Path(self.toml_file_name)
165
+
135
166
  config_ini_section: str = None # type:ignore[assignment]
136
167
  """Name of the config file section to read basic configuration
137
168
  from. Defaults to ``alembic``, that is the ``[alembic]`` section
@@ -187,36 +218,67 @@ class Config:
187
218
  def file_config(self) -> ConfigParser:
188
219
  """Return the underlying ``ConfigParser`` object.
189
220
 
190
- Direct access to the .ini file is available here,
221
+ Dir*-ect access to the .ini file is available here,
191
222
  though the :meth:`.Config.get_section` and
192
223
  :meth:`.Config.get_main_option`
193
224
  methods provide a possibly simpler interface.
194
225
 
195
226
  """
196
227
 
197
- if self.config_file_name:
198
- here = os.path.abspath(os.path.dirname(self.config_file_name))
228
+ if self._config_file_path:
229
+ here = self._config_file_path.absolute().parent
199
230
  else:
200
- here = ""
201
- self.config_args["here"] = here
231
+ here = Path()
232
+ self.config_args["here"] = here.as_posix()
202
233
  file_config = ConfigParser(self.config_args)
203
- if self.config_file_name:
204
- compat.read_config_parser(file_config, [self.config_file_name])
234
+ if self._config_file_path:
235
+ compat.read_config_parser(file_config, [self._config_file_path])
205
236
  else:
206
237
  file_config.add_section(self.config_ini_section)
207
238
  return file_config
208
239
 
240
+ @util.memoized_property
241
+ def toml_alembic_config(self) -> Mapping[str, Any]:
242
+ """Return a dictionary of the [tool.alembic] section from
243
+ pyproject.toml"""
244
+
245
+ if self._toml_file_path and self._toml_file_path.exists():
246
+
247
+ here = self._toml_file_path.absolute().parent
248
+ self.toml_args["here"] = here.as_posix()
249
+
250
+ with open(self._toml_file_path, "rb") as f:
251
+ toml_data = compat.tomllib.load(f)
252
+ data = toml_data.get("tool", {}).get("alembic", {})
253
+ if not isinstance(data, dict):
254
+ raise util.CommandError("Incorrect TOML format")
255
+ return data
256
+
257
+ else:
258
+ return {}
259
+
209
260
  def get_template_directory(self) -> str:
210
261
  """Return the directory where Alembic setup templates are found.
211
262
 
212
263
  This method is used by the alembic ``init`` and ``list_templates``
213
264
  commands.
214
265
 
266
+ """
267
+ return self._get_template_path().as_posix()
268
+
269
+ def _get_template_path(self) -> Path:
270
+ """Return the directory where Alembic setup templates are found.
271
+
272
+ This method is used by the alembic ``init`` and ``list_templates``
273
+ commands.
274
+
275
+ .. versionadded:: 1.16.0
276
+
215
277
  """
216
278
  import alembic
217
279
 
218
- package_dir = os.path.abspath(os.path.dirname(alembic.__file__))
219
- return os.path.join(package_dir, "templates")
280
+ package_dir = Path(alembic.__file__).absolute().parent
281
+ return package_dir / "templates"
220
282
 
221
283
  @overload
222
284
  def get_section(
@@ -278,6 +340,12 @@ class Config:
278
340
  The value here will override whatever was in the .ini
279
341
  file.
280
342
 
343
+ Does **NOT** consume from the pyproject.toml file.
344
+
345
+ .. seealso::
346
+
347
+ :meth:`.Config.get_alembic_option` - includes pyproject support
348
+
281
349
  :param section: name of the section
282
350
 
283
351
  :param name: name of the value
@@ -326,9 +394,85 @@ class Config:
326
394
  section, unless the ``-n/--name`` flag were used to
327
395
  indicate a different section.
328
396
 
397
+ Does **NOT** consume from the pyproject.toml file.
398
+
399
+ .. seealso::
400
+
401
+ :meth:`.Config.get_alembic_option` - includes pyproject support
402
+
329
403
  """
330
404
  return self.get_section_option(self.config_ini_section, name, default)
331
405
 
406
+ @overload
407
+ def get_alembic_option(self, name: str, default: str) -> str: ...
408
+
409
+ @overload
410
+ def get_alembic_option(
411
+ self, name: str, default: Optional[str] = None
412
+ ) -> Optional[str]: ...
413
+
414
+ def get_alembic_option(
415
+ self, name: str, default: Optional[str] = None
416
+ ) -> Union[None, str, list[str], dict[str, str], list[dict[str, str]]]:
417
+ """Return an option from the "[alembic]" or "[tool.alembic]" section
418
+ of the configparser-parsed .ini file (e.g. ``alembic.ini``) or
419
+ toml-parsed ``pyproject.toml`` file.
420
+
421
+ The value returned is expected to be None, string, list of strings,
422
+ or dictionary of strings. Within each type of string value, the
423
+ ``%(here)s`` token is substituted out with the absolute path of the
424
+ ``pyproject.toml`` file, as are other tokens which are extracted from
425
+ the :paramref:`.Config.config_args` dictionary.
426
+
427
+ Searches always prioritize the configparser namespace first, before
428
+ searching in the toml namespace.
429
+
430
+ If Alembic was run using the ``-n/--name`` flag to indicate an
431
+ alternate main section name, this is taken into account **only** for
432
+ the configparser-parsed .ini file. The section name in toml is always
433
+ ``[tool.alembic]``.
434
+
435
+
436
+ .. versionadded:: 1.16.0
437
+
438
+ """
439
+
440
+ if self.file_config.has_option(self.config_ini_section, name):
441
+ return self.file_config.get(self.config_ini_section, name)
442
+ else:
443
+ return self._get_toml_config_value(name, default=default)
444
+
445
+ def _get_toml_config_value(
446
+ self, name: str, default: Optional[Any] = None
447
+ ) -> Union[None, str, list[str], dict[str, str], list[dict[str, str]]]:
448
+ USE_DEFAULT = object()
449
+ value: Union[None, str, list[str], dict[str, str]] = (
450
+ self.toml_alembic_config.get(name, USE_DEFAULT)
451
+ )
452
+ if value is USE_DEFAULT:
453
+ return default
454
+ if value is not None:
455
+ if isinstance(value, str):
456
+ value = value % (self.toml_args)
457
+ elif isinstance(value, list):
458
+ if value and isinstance(value[0], dict):
459
+ value = [
460
+ {k: v % (self.toml_args) for k, v in dv.items()}
461
+ for dv in value
462
+ ]
463
+ else:
464
+ value = cast(
465
+ "list[str]", [v % (self.toml_args) for v in value]
466
+ )
467
+ elif isinstance(value, dict):
468
+ value = cast(
469
+ "dict[str, str]",
470
+ {k: v % (self.toml_args) for k, v in value.items()},
471
+ )
472
+ else:
473
+ raise util.CommandError("unsupported TOML value type")
474
+ return value
475
+
332
476
  @util.memoized_property
333
477
  def messaging_opts(self) -> MessagingOptions:
334
478
  """The messaging options."""
@@ -339,181 +483,313 @@ class Config:
339
483
  ),
340
484
  )
341
485
 
486
+ def _get_file_separator_char(self, *names: str) -> Optional[str]:
487
+ for name in names:
488
+ separator = self.get_main_option(name)
489
+ if separator is not None:
490
+ break
491
+ else:
492
+ return None
493
+
494
+ split_on_path = {
495
+ "space": " ",
496
+ "newline": "\n",
497
+ "os": os.pathsep,
498
+ ":": ":",
499
+ ";": ";",
500
+ }
501
+
502
+ try:
503
+ sep = split_on_path[separator]
504
+ except KeyError as ke:
505
+ raise ValueError(
506
+ "'%s' is not a valid value for %s; "
507
+ "expected 'space', 'newline', 'os', ':', ';'"
508
+ % (separator, name)
509
+ ) from ke
510
+ else:
511
+ if name == "version_path_separator":
512
+ util.warn_deprecated(
513
+ "The version_path_separator configuration parameter "
514
+ "is deprecated; please use path_separator"
515
+ )
516
+ return sep
517
+
518
+ def get_version_locations_list(self) -> Optional[list[str]]:
519
+
520
+ version_locations_str = self.file_config.get(
521
+ self.config_ini_section, "version_locations", fallback=None
522
+ )
523
+
524
+ if version_locations_str:
525
+ split_char = self._get_file_separator_char(
526
+ "path_separator", "version_path_separator"
527
+ )
528
+
529
+ if split_char is None:
530
+
531
+ # legacy behaviour for backwards compatibility
532
+ util.warn_deprecated(
533
+ "No path_separator found in configuration; "
534
+ "falling back to legacy splitting on spaces/commas "
535
+ "for version_locations. Consider adding "
536
+ "path_separator=os to Alembic config."
537
+ )
538
+
539
+ _split_on_space_comma = re.compile(r", *|(?: +)")
540
+ return _split_on_space_comma.split(version_locations_str)
541
+ else:
542
+ return [
543
+ x.strip()
544
+ for x in version_locations_str.split(split_char)
545
+ if x
546
+ ]
547
+ else:
548
+ return cast(
549
+ "list[str]",
550
+ self._get_toml_config_value("version_locations", None),
551
+ )
552
+
553
+ def get_prepend_sys_paths_list(self) -> Optional[list[str]]:
554
+ prepend_sys_path_str = self.file_config.get(
555
+ self.config_ini_section, "prepend_sys_path", fallback=None
556
+ )
557
+
558
+ if prepend_sys_path_str:
559
+ split_char = self._get_file_separator_char("path_separator")
560
+
561
+ if split_char is None:
562
+
563
+ # legacy behaviour for backwards compatibility
564
+ util.warn_deprecated(
565
+ "No path_separator found in configuration; "
566
+ "falling back to legacy splitting on spaces, commas, "
567
+ "and colons for prepend_sys_path. Consider adding "
568
+ "path_separator=os to Alembic config."
569
+ )
570
+
571
+ _split_on_space_comma_colon = re.compile(r", *|(?: +)|\:")
572
+ return _split_on_space_comma_colon.split(prepend_sys_path_str)
573
+ else:
574
+ return [
575
+ x.strip()
576
+ for x in prepend_sys_path_str.split(split_char)
577
+ if x
578
+ ]
579
+ else:
580
+ return cast(
581
+ "list[str]",
582
+ self._get_toml_config_value("prepend_sys_path", None),
583
+ )
584
+
585
+ def get_hooks_list(self) -> list[PostWriteHookConfig]:
586
+
587
+ hooks: list[PostWriteHookConfig] = []
588
+
589
+ if not self.file_config.has_section("post_write_hooks"):
590
+ toml_hook_config = cast(
591
+ "list[dict[str, str]]",
592
+ self._get_toml_config_value("post_write_hooks", []),
593
+ )
594
+ for cfg in toml_hook_config:
595
+ opts = dict(cfg)
596
+ opts["_hook_name"] = opts.pop("name")
597
+ hooks.append(opts)
598
+
599
+ else:
600
+ _split_on_space_comma = re.compile(r", *|(?: +)")
601
+ ini_hook_config = self.get_section("post_write_hooks", {})
602
+ names = _split_on_space_comma.split(
603
+ ini_hook_config.get("hooks", "")
604
+ )
605
+
606
+ for name in names:
607
+ if not name:
608
+ continue
609
+ opts = {
610
+ key[len(name) + 1 :]: ini_hook_config[key]
611
+ for key in ini_hook_config
612
+ if key.startswith(name + ".")
613
+ }
614
+
615
+ opts["_hook_name"] = name
616
+ hooks.append(opts)
617
+
618
+ return hooks
619
+
620
+
621
+ PostWriteHookConfig = Mapping[str, str]
622
+
342
623
 
343
624
  class MessagingOptions(TypedDict, total=False):
344
625
  quiet: bool
345
626
 
346
627
 
628
+ class CommandFunction(Protocol):
629
+ """A function that may be registered in the CLI as an alembic command.
630
+ It must be a named function and it must accept a :class:`.Config` object
631
+ as the first argument.
632
+
633
+ .. versionadded:: 1.15.3
634
+
635
+ """
636
+
637
+ __name__: str
638
+
639
+ def __call__(self, config: Config, *args: Any, **kwargs: Any) -> Any: ...
640
+
641
+
347
642
  class CommandLine:
643
+ """Provides the command line interface to Alembic."""
644
+
348
645
  def __init__(self, prog: Optional[str] = None) -> None:
349
646
  self._generate_args(prog)
350
647
 
351
- def _generate_args(self, prog: Optional[str]) -> None:
352
- def add_options(
353
- fn: Any, parser: Any, positional: Any, kwargs: Any
354
- ) -> None:
355
- kwargs_opts = {
356
- "template": (
357
- "-t",
358
- "--template",
359
- dict(
360
- default="generic",
361
- type=str,
362
- help="Setup template for use with 'init'",
363
- ),
364
- ),
365
- "message": (
366
- "-m",
367
- "--message",
368
- dict(
369
- type=str, help="Message string to use with 'revision'"
370
- ),
371
- ),
372
- "sql": (
373
- "--sql",
374
- dict(
375
- action="store_true",
376
- help="Don't emit SQL to database - dump to "
377
- "standard output/file instead. See docs on "
378
- "offline mode.",
379
- ),
380
- ),
381
- "tag": (
382
- "--tag",
383
- dict(
384
- type=str,
385
- help="Arbitrary 'tag' name - can be used by "
386
- "custom env.py scripts.",
387
- ),
388
- ),
389
- "head": (
390
- "--head",
391
- dict(
392
- type=str,
393
- help="Specify head revision or <branchname>@head "
394
- "to base new revision on.",
395
- ),
396
- ),
397
- "splice": (
398
- "--splice",
399
- dict(
400
- action="store_true",
401
- help="Allow a non-head revision as the "
402
- "'head' to splice onto",
403
- ),
404
- ),
405
- "depends_on": (
406
- "--depends-on",
407
- dict(
408
- action="append",
409
- help="Specify one or more revision identifiers "
410
- "which this revision should depend on.",
411
- ),
412
- ),
413
- "rev_id": (
414
- "--rev-id",
415
- dict(
416
- type=str,
417
- help="Specify a hardcoded revision id instead of "
418
- "generating one",
419
- ),
420
- ),
421
- "version_path": (
422
- "--version-path",
423
- dict(
424
- type=str,
425
- help="Specify specific path from config for "
426
- "version file",
427
- ),
428
- ),
429
- "branch_label": (
430
- "--branch-label",
431
- dict(
432
- type=str,
433
- help="Specify a branch label to apply to the "
434
- "new revision",
435
- ),
436
- ),
437
- "verbose": (
438
- "-v",
439
- "--verbose",
440
- dict(action="store_true", help="Use more verbose output"),
441
- ),
442
- "resolve_dependencies": (
443
- "--resolve-dependencies",
444
- dict(
445
- action="store_true",
446
- help="Treat dependency versions as down revisions",
447
- ),
448
- ),
449
- "autogenerate": (
450
- "--autogenerate",
451
- dict(
452
- action="store_true",
453
- help="Populate revision script with candidate "
454
- "migration operations, based on comparison "
455
- "of database to model.",
456
- ),
457
- ),
458
- "rev_range": (
459
- "-r",
460
- "--rev-range",
461
- dict(
462
- action="store",
463
- help="Specify a revision range; "
464
- "format is [start]:[end]",
465
- ),
466
- ),
467
- "indicate_current": (
468
- "-i",
469
- "--indicate-current",
470
- dict(
471
- action="store_true",
472
- help="Indicate the current revision",
473
- ),
474
- ),
475
- "purge": (
476
- "--purge",
477
- dict(
478
- action="store_true",
479
- help="Unconditionally erase the version table "
480
- "before stamping",
481
- ),
482
- ),
483
- "package": (
484
- "--package",
485
- dict(
486
- action="store_true",
487
- help="Write empty __init__.py files to the "
488
- "environment and version locations",
489
- ),
490
- ),
491
- }
492
- positional_help = {
493
- "directory": "location of scripts directory",
494
- "revision": "revision identifier",
495
- "revisions": "one or more revisions, or 'heads' for all heads",
496
- }
497
- for arg in kwargs:
498
- if arg in kwargs_opts:
499
- args = kwargs_opts[arg]
500
- args, kw = args[0:-1], args[-1]
501
- parser.add_argument(*args, **kw)
502
-
503
- for arg in positional:
504
- if (
505
- arg == "revisions"
506
- or fn in positional_translations
507
- and positional_translations[fn][arg] == "revisions"
508
- ):
509
- subparser.add_argument(
510
- "revisions",
511
- nargs="+",
512
- help=positional_help.get("revisions"),
513
- )
514
- else:
515
- subparser.add_argument(arg, help=positional_help.get(arg))
648
+ _KWARGS_OPTS = {
649
+ "template": (
650
+ "-t",
651
+ "--template",
652
+ dict(
653
+ default="generic",
654
+ type=str,
655
+ help="Setup template for use with 'init'",
656
+ ),
657
+ ),
658
+ "message": (
659
+ "-m",
660
+ "--message",
661
+ dict(type=str, help="Message string to use with 'revision'"),
662
+ ),
663
+ "sql": (
664
+ "--sql",
665
+ dict(
666
+ action="store_true",
667
+ help="Don't emit SQL to database - dump to "
668
+ "standard output/file instead. See docs on "
669
+ "offline mode.",
670
+ ),
671
+ ),
672
+ "tag": (
673
+ "--tag",
674
+ dict(
675
+ type=str,
676
+ help="Arbitrary 'tag' name - can be used by "
677
+ "custom env.py scripts.",
678
+ ),
679
+ ),
680
+ "head": (
681
+ "--head",
682
+ dict(
683
+ type=str,
684
+ help="Specify head revision or <branchname>@head "
685
+ "to base new revision on.",
686
+ ),
687
+ ),
688
+ "splice": (
689
+ "--splice",
690
+ dict(
691
+ action="store_true",
692
+ help="Allow a non-head revision as the 'head' to splice onto",
693
+ ),
694
+ ),
695
+ "depends_on": (
696
+ "--depends-on",
697
+ dict(
698
+ action="append",
699
+ help="Specify one or more revision identifiers "
700
+ "which this revision should depend on.",
701
+ ),
702
+ ),
703
+ "rev_id": (
704
+ "--rev-id",
705
+ dict(
706
+ type=str,
707
+ help="Specify a hardcoded revision id instead of "
708
+ "generating one",
709
+ ),
710
+ ),
711
+ "version_path": (
712
+ "--version-path",
713
+ dict(
714
+ type=str,
715
+ help="Specify specific path from config for version file",
716
+ ),
717
+ ),
718
+ "branch_label": (
719
+ "--branch-label",
720
+ dict(
721
+ type=str,
722
+ help="Specify a branch label to apply to the new revision",
723
+ ),
724
+ ),
725
+ "verbose": (
726
+ "-v",
727
+ "--verbose",
728
+ dict(action="store_true", help="Use more verbose output"),
729
+ ),
730
+ "resolve_dependencies": (
731
+ "--resolve-dependencies",
732
+ dict(
733
+ action="store_true",
734
+ help="Treat dependency versions as down revisions",
735
+ ),
736
+ ),
737
+ "autogenerate": (
738
+ "--autogenerate",
739
+ dict(
740
+ action="store_true",
741
+ help="Populate revision script with candidate "
742
+ "migration operations, based on comparison "
743
+ "of database to model.",
744
+ ),
745
+ ),
746
+ "rev_range": (
747
+ "-r",
748
+ "--rev-range",
749
+ dict(
750
+ action="store",
751
+ help="Specify a revision range; format is [start]:[end]",
752
+ ),
753
+ ),
754
+ "indicate_current": (
755
+ "-i",
756
+ "--indicate-current",
757
+ dict(
758
+ action="store_true",
759
+ help="Indicate the current revision",
760
+ ),
761
+ ),
762
+ "purge": (
763
+ "--purge",
764
+ dict(
765
+ action="store_true",
766
+ help="Unconditionally erase the version table before stamping",
767
+ ),
768
+ ),
769
+ "package": (
770
+ "--package",
771
+ dict(
772
+ action="store_true",
773
+ help="Write empty __init__.py files to the "
774
+ "environment and version locations",
775
+ ),
776
+ ),
777
+ }
778
+ _POSITIONAL_OPTS = {
779
+ "directory": dict(help="location of scripts directory"),
780
+ "revision": dict(
781
+ help="revision identifier",
782
+ ),
783
+ "revisions": dict(
784
+ nargs="+",
785
+ help="one or more revisions, or 'heads' for all heads",
786
+ ),
787
+ }
788
+ _POSITIONAL_TRANSLATIONS: dict[Any, dict[str, str]] = {
789
+ command.stamp: {"revision": "revisions"}
790
+ }
516
791
 
792
+ def _generate_args(self, prog: Optional[str]) -> None:
517
793
  parser = ArgumentParser(prog=prog)
518
794
 
519
795
  parser.add_argument(
@@ -522,17 +798,19 @@ class CommandLine:
522
798
  parser.add_argument(
523
799
  "-c",
524
800
  "--config",
525
- type=str,
526
- default=os.environ.get("ALEMBIC_CONFIG", "alembic.ini"),
801
+ action="append",
527
802
  help="Alternate config file; defaults to value of "
528
- 'ALEMBIC_CONFIG environment variable, or "alembic.ini"',
803
+ 'ALEMBIC_CONFIG environment variable, or "alembic.ini". '
804
+ "May also refer to pyproject.toml file. May be specified twice "
805
+ "to reference both files separately",
529
806
  )
530
807
  parser.add_argument(
531
808
  "-n",
532
809
  "--name",
533
810
  type=str,
534
811
  default="alembic",
535
- help="Name of section in .ini file to " "use for Alembic config",
812
+ help="Name of section in .ini file to use for Alembic config "
813
+ "(only applies to configparser config, not toml)",
536
814
  )
537
815
  parser.add_argument(
538
816
  "-x",
@@ -552,50 +830,81 @@ class CommandLine:
552
830
  action="store_true",
553
831
  help="Do not log to std output.",
554
832
  )
555
- subparsers = parser.add_subparsers()
556
833
 
557
- positional_translations: Dict[Any, Any] = {
558
- command.stamp: {"revision": "revisions"}
559
- }
560
-
561
- for fn in [getattr(command, n) for n in dir(command)]:
834
+ self.subparsers = parser.add_subparsers()
835
+ alembic_commands = (
836
+ cast(CommandFunction, fn)
837
+ for fn in (getattr(command, name) for name in dir(command))
562
838
  if (
563
839
  inspect.isfunction(fn)
564
840
  and fn.__name__[0] != "_"
565
841
  and fn.__module__ == "alembic.command"
566
- ):
567
- spec = compat.inspect_getfullargspec(fn)
568
- if spec[3] is not None:
569
- positional = spec[0][1 : -len(spec[3])]
570
- kwarg = spec[0][-len(spec[3]) :]
571
- else:
572
- positional = spec[0][1:]
573
- kwarg = []
842
+ )
843
+ )
574
844
 
575
- if fn in positional_translations:
576
- positional = [
577
- positional_translations[fn].get(name, name)
578
- for name in positional
579
- ]
845
+ for fn in alembic_commands:
846
+ self.register_command(fn)
580
847
 
581
- # parse first line(s) of helptext without a line break
582
- help_ = fn.__doc__
583
- if help_:
584
- help_text = []
585
- for line in help_.split("\n"):
586
- if not line.strip():
587
- break
588
- else:
589
- help_text.append(line.strip())
590
- else:
591
- help_text = []
592
- subparser = subparsers.add_parser(
593
- fn.__name__, help=" ".join(help_text)
594
- )
595
- add_options(fn, subparser, positional, kwarg)
596
- subparser.set_defaults(cmd=(fn, positional, kwarg))
597
848
  self.parser = parser
598
849
 
850
+ def register_command(self, fn: CommandFunction) -> None:
851
+ """Registers a function as a CLI subcommand. The subcommand name
852
+ matches the function name, the arguments are extracted from the
853
+ signature and the help text is read from the docstring.
854
+
855
+ .. versionadded:: 1.15.3
856
+
857
+ .. seealso::
858
+
859
+ :ref:`custom_commandline`
860
+ """
861
+
862
+ positional, kwarg, help_text = self._inspect_function(fn)
863
+
864
+ subparser = self.subparsers.add_parser(fn.__name__, help=help_text)
865
+ subparser.set_defaults(cmd=(fn, positional, kwarg))
866
+
867
+ for arg in kwarg:
868
+ if arg in self._KWARGS_OPTS:
869
+ kwarg_opt = self._KWARGS_OPTS[arg]
870
+ args, opts = kwarg_opt[0:-1], kwarg_opt[-1]
871
+ subparser.add_argument(*args, **opts) # type:ignore
872
+
873
+ for arg in positional:
874
+ opts = self._POSITIONAL_OPTS.get(arg, {})
875
+ subparser.add_argument(arg, **opts) # type:ignore
876
+
877
+ def _inspect_function(self, fn: CommandFunction) -> tuple[Any, Any, str]:
878
+ spec = compat.inspect_getfullargspec(fn)
879
+ if spec[3] is not None:
880
+ positional = spec[0][1 : -len(spec[3])]
881
+ kwarg = spec[0][-len(spec[3]) :]
882
+ else:
883
+ positional = spec[0][1:]
884
+ kwarg = []
885
+
886
+ if fn in self._POSITIONAL_TRANSLATIONS:
887
+ positional = [
888
+ self._POSITIONAL_TRANSLATIONS[fn].get(name, name)
889
+ for name in positional
890
+ ]
891
+
892
+ # parse first line(s) of helptext without a line break
893
+ help_ = fn.__doc__
894
+ if help_:
895
+ help_lines = []
896
+ for line in help_.split("\n"):
897
+ if not line.strip():
898
+ break
899
+ else:
900
+ help_lines.append(line.strip())
901
+ else:
902
+ help_lines = []
903
+
904
+ help_text = " ".join(help_lines)
905
+
906
+ return positional, kwarg, help_text
907
+
599
908
  def run_cmd(self, config: Config, options: Namespace) -> None:
600
909
  fn, positional, kwarg = options.cmd
601
910
 
@@ -611,15 +920,58 @@ class CommandLine:
611
920
  else:
612
921
  util.err(str(e), **config.messaging_opts)
613
922
 
923
+ def _inis_from_config(self, options: Namespace) -> tuple[str, str]:
924
+ names = options.config
925
+
926
+ alembic_config_env = os.environ.get("ALEMBIC_CONFIG")
927
+ if (
928
+ alembic_config_env
929
+ and os.path.basename(alembic_config_env) == "pyproject.toml"
930
+ ):
931
+ default_pyproject_toml = alembic_config_env
932
+ default_alembic_config = "alembic.ini"
933
+ elif alembic_config_env:
934
+ default_pyproject_toml = "pyproject.toml"
935
+ default_alembic_config = alembic_config_env
936
+ else:
937
+ default_alembic_config = "alembic.ini"
938
+ default_pyproject_toml = "pyproject.toml"
939
+
940
+ if not names:
941
+ return default_pyproject_toml, default_alembic_config
942
+
943
+ toml = ini = None
944
+
945
+ for name in names:
946
+ if os.path.basename(name) == "pyproject.toml":
947
+ if toml is not None:
948
+ raise util.CommandError(
949
+ "pyproject.toml indicated more than once"
950
+ )
951
+ toml = name
952
+ else:
953
+ if ini is not None:
954
+ raise util.CommandError(
955
+ "only one ini file may be indicated"
956
+ )
957
+ ini = name
958
+
959
+ return toml if toml else default_pyproject_toml, (
960
+ ini if ini else default_alembic_config
961
+ )
962
+
614
963
  def main(self, argv: Optional[Sequence[str]] = None) -> None:
964
+ """Executes the command line with the provided arguments."""
615
965
  options = self.parser.parse_args(argv)
616
966
  if not hasattr(options, "cmd"):
617
967
  # see http://bugs.python.org/issue9253, argparse
618
968
  # behavior changed incompatibly in py3.3
619
969
  self.parser.error("too few arguments")
620
970
  else:
971
+ toml, ini = self._inis_from_config(options)
621
972
  cfg = Config(
622
- file_=options.config,
973
+ file_=ini,
974
+ toml_file=toml,
623
975
  ini_section=options.name,
624
976
  cmd_opts=options,
625
977
  )