main commit
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-10-16 16:30:25 +09:00
parent 91c7e04474
commit 537e7b363f
1146 changed files with 45926 additions and 77196 deletions

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from contextlib import contextmanager
import datetime
import os
from pathlib import Path
import re
import shutil
import sys
@@ -12,6 +11,7 @@ from typing import Any
from typing import cast
from typing import Iterator
from typing import List
from typing import Mapping
from typing import Optional
from typing import Sequence
from typing import Set
@@ -23,9 +23,7 @@ from . import revision
from . import write_hooks
from .. import util
from ..runtime import migration
from ..util import compat
from ..util import not_none
from ..util.pyfiles import _preserving_path_as_str
if TYPE_CHECKING:
from .revision import _GetRevArg
@@ -33,28 +31,26 @@ if TYPE_CHECKING:
from .revision import Revision
from ..config import Config
from ..config import MessagingOptions
from ..config import PostWriteHookConfig
from ..runtime.migration import RevisionStep
from ..runtime.migration import StampStep
try:
if compat.py39:
from zoneinfo import ZoneInfo
from zoneinfo import ZoneInfoNotFoundError
else:
from backports.zoneinfo import ZoneInfo # type: ignore[import-not-found,no-redef] # noqa: E501
from backports.zoneinfo import ZoneInfoNotFoundError # type: ignore[no-redef] # noqa: E501
from dateutil import tz
except ImportError:
ZoneInfo = None # type: ignore[assignment, misc]
tz = None # type: ignore[assignment]
_sourceless_rev_file = re.compile(r"(?!\.\#|__init__)(.*\.py)(c|o)?$")
_only_source_rev_file = re.compile(r"(?!\.\#|__init__)(.*\.py)$")
_legacy_rev = re.compile(r"([a-f0-9]+)\.py$")
_slug_re = re.compile(r"\w+")
_default_file_template = "%(rev)s_%(slug)s"
_split_on_space_comma = re.compile(r", *|(?: +)")
_split_on_space_comma_colon = re.compile(r", *|(?: +)|\:")
class ScriptDirectory:
"""Provides operations upon an Alembic script directory.
This object is useful to get information as to current revisions,
@@ -76,55 +72,40 @@ class ScriptDirectory:
def __init__(
self,
dir: Union[str, os.PathLike[str]], # noqa: A002
dir: str, # noqa
file_template: str = _default_file_template,
truncate_slug_length: Optional[int] = 40,
version_locations: Optional[
Sequence[Union[str, os.PathLike[str]]]
] = None,
version_locations: Optional[List[str]] = None,
sourceless: bool = False,
output_encoding: str = "utf-8",
timezone: Optional[str] = None,
hooks: list[PostWriteHookConfig] = [],
hook_config: Optional[Mapping[str, str]] = None,
recursive_version_locations: bool = False,
messaging_opts: MessagingOptions = cast(
"MessagingOptions", util.EMPTY_DICT
),
) -> None:
self.dir = _preserving_path_as_str(dir)
self.version_locations = [
_preserving_path_as_str(p) for p in version_locations or ()
]
self.dir = dir
self.file_template = file_template
self.version_locations = version_locations
self.truncate_slug_length = truncate_slug_length or 40
self.sourceless = sourceless
self.output_encoding = output_encoding
self.revision_map = revision.RevisionMap(self._load_revisions)
self.timezone = timezone
self.hooks = hooks
self.hook_config = hook_config
self.recursive_version_locations = recursive_version_locations
self.messaging_opts = messaging_opts
if not os.access(dir, os.F_OK):
raise util.CommandError(
f"Path doesn't exist: {dir}. Please use "
"Path doesn't exist: %r. Please use "
"the 'init' command to create a new "
"scripts folder."
"scripts folder." % os.path.abspath(dir)
)
@property
def versions(self) -> str:
"""return a single version location based on the sole path passed
within version_locations.
If multiple version locations are configured, an error is raised.
"""
return str(self._singular_version_location)
@util.memoized_property
def _singular_version_location(self) -> Path:
loc = self._version_locations
if len(loc) > 1:
raise util.CommandError("Multiple version_locations present")
@@ -132,31 +113,40 @@ class ScriptDirectory:
return loc[0]
@util.memoized_property
def _version_locations(self) -> Sequence[Path]:
def _version_locations(self):
if self.version_locations:
return [
util.coerce_resource_to_filename(location).absolute()
os.path.abspath(util.coerce_resource_to_filename(location))
for location in self.version_locations
]
else:
return [Path(self.dir, "versions").absolute()]
return (os.path.abspath(os.path.join(self.dir, "versions")),)
def _load_revisions(self) -> Iterator[Script]:
paths = [vers for vers in self._version_locations if vers.exists()]
if self.version_locations:
paths = [
vers
for vers in self._version_locations
if os.path.exists(vers)
]
else:
paths = [self.versions]
dupes = set()
for vers in paths:
for file_path in Script._list_py_dir(self, vers):
real_path = file_path.resolve()
real_path = os.path.realpath(file_path)
if real_path in dupes:
util.warn(
f"File {real_path} loaded twice! ignoring. "
"Please ensure version_locations is unique."
"File %s loaded twice! ignoring. Please ensure "
"version_locations is unique." % real_path
)
continue
dupes.add(real_path)
script = Script._from_path(self, real_path)
filename = os.path.basename(real_path)
dir_name = os.path.dirname(real_path)
script = Script._from_filename(self, dir_name, filename)
if script is None:
continue
yield script
@@ -170,36 +160,74 @@ class ScriptDirectory:
present.
"""
script_location = config.get_alembic_option("script_location")
script_location = config.get_main_option("script_location")
if script_location is None:
raise util.CommandError(
"No 'script_location' key found in configuration."
"No 'script_location' key " "found in configuration."
)
truncate_slug_length: Optional[int]
tsl = config.get_alembic_option("truncate_slug_length")
tsl = config.get_main_option("truncate_slug_length")
if tsl is not None:
truncate_slug_length = int(tsl)
else:
truncate_slug_length = None
prepend_sys_path = config.get_prepend_sys_paths_list()
if prepend_sys_path:
sys.path[:0] = prepend_sys_path
version_locations_str = config.get_main_option("version_locations")
version_locations: Optional[List[str]]
if version_locations_str:
version_path_separator = config.get_main_option(
"version_path_separator"
)
rvl = config.get_alembic_boolean_option("recursive_version_locations")
split_on_path = {
None: None,
"space": " ",
"os": os.pathsep,
":": ":",
";": ";",
}
try:
split_char: Optional[str] = split_on_path[
version_path_separator
]
except KeyError as ke:
raise ValueError(
"'%s' is not a valid value for "
"version_path_separator; "
"expected 'space', 'os', ':', ';'" % version_path_separator
) from ke
else:
if split_char is None:
# legacy behaviour for backwards compatibility
version_locations = _split_on_space_comma.split(
version_locations_str
)
else:
version_locations = [
x for x in version_locations_str.split(split_char) if x
]
else:
version_locations = None
prepend_sys_path = config.get_main_option("prepend_sys_path")
if prepend_sys_path:
sys.path[:0] = list(
_split_on_space_comma_colon.split(prepend_sys_path)
)
rvl = config.get_main_option("recursive_version_locations") == "true"
return ScriptDirectory(
util.coerce_resource_to_filename(script_location),
file_template=config.get_alembic_option(
file_template=config.get_main_option(
"file_template", _default_file_template
),
truncate_slug_length=truncate_slug_length,
sourceless=config.get_alembic_boolean_option("sourceless"),
output_encoding=config.get_alembic_option(
"output_encoding", "utf-8"
),
version_locations=config.get_version_locations_list(),
timezone=config.get_alembic_option("timezone"),
hooks=config.get_hooks_list(),
sourceless=config.get_main_option("sourceless") == "true",
output_encoding=config.get_main_option("output_encoding", "utf-8"),
version_locations=version_locations,
timezone=config.get_main_option("timezone"),
hook_config=config.get_section("post_write_hooks", {}),
recursive_version_locations=rvl,
messaging_opts=config.messaging_opts,
)
@@ -269,22 +297,24 @@ class ScriptDirectory:
):
yield cast(Script, rev)
def get_revisions(self, id_: _GetRevArg) -> Tuple[Script, ...]:
def get_revisions(self, id_: _GetRevArg) -> Tuple[Optional[Script], ...]:
"""Return the :class:`.Script` instance with the given rev identifier,
symbolic name, or sequence of identifiers.
"""
with self._catch_revision_errors():
return cast(
Tuple[Script, ...],
Tuple[Optional[Script], ...],
self.revision_map.get_revisions(id_),
)
def get_all_current(self, id_: Tuple[str, ...]) -> Set[Script]:
def get_all_current(self, id_: Tuple[str, ...]) -> Set[Optional[Script]]:
with self._catch_revision_errors():
return cast(Set[Script], self.revision_map._get_all_current(id_))
return cast(
Set[Optional[Script]], self.revision_map._get_all_current(id_)
)
def get_revision(self, id_: str) -> Script:
def get_revision(self, id_: str) -> Optional[Script]:
"""Return the :class:`.Script` instance with the given rev id.
.. seealso::
@@ -294,7 +324,7 @@ class ScriptDirectory:
"""
with self._catch_revision_errors():
return cast(Script, self.revision_map.get_revision(id_))
return cast(Optional[Script], self.revision_map.get_revision(id_))
def as_revision_number(
self, id_: Optional[str]
@@ -549,37 +579,24 @@ class ScriptDirectory:
util.load_python_file(self.dir, "env.py")
@property
def env_py_location(self) -> str:
return str(Path(self.dir, "env.py"))
def env_py_location(self):
return os.path.abspath(os.path.join(self.dir, "env.py"))
def _append_template(self, src: Path, dest: Path, **kw: Any) -> None:
def _generate_template(self, src: str, dest: str, **kw: Any) -> None:
with util.status(
f"Appending to existing {dest.absolute()}",
**self.messaging_opts,
):
util.template_to_file(
src,
dest,
self.output_encoding,
append_with_newlines=True,
**kw,
)
def _generate_template(self, src: Path, dest: Path, **kw: Any) -> None:
with util.status(
f"Generating {dest.absolute()}", **self.messaging_opts
f"Generating {os.path.abspath(dest)}", **self.messaging_opts
):
util.template_to_file(src, dest, self.output_encoding, **kw)
def _copy_file(self, src: Path, dest: Path) -> None:
def _copy_file(self, src: str, dest: str) -> None:
with util.status(
f"Generating {dest.absolute()}", **self.messaging_opts
f"Generating {os.path.abspath(dest)}", **self.messaging_opts
):
shutil.copy(src, dest)
def _ensure_directory(self, path: Path) -> None:
path = path.absolute()
if not path.exists():
def _ensure_directory(self, path: str) -> None:
path = os.path.abspath(path)
if not os.path.exists(path):
with util.status(
f"Creating directory {path}", **self.messaging_opts
):
@@ -587,27 +604,25 @@ class ScriptDirectory:
def _generate_create_date(self) -> datetime.datetime:
if self.timezone is not None:
if ZoneInfo is None:
if tz is None:
raise util.CommandError(
"Python >= 3.9 is required for timezone support or "
"the 'backports.zoneinfo' package must be installed."
"The library 'python-dateutil' is required "
"for timezone support"
)
# First, assume correct capitalization
try:
tzinfo = ZoneInfo(self.timezone)
except ZoneInfoNotFoundError:
tzinfo = None
tzinfo = tz.gettz(self.timezone)
if tzinfo is None:
try:
tzinfo = ZoneInfo(self.timezone.upper())
except ZoneInfoNotFoundError:
raise util.CommandError(
"Can't locate timezone: %s" % self.timezone
) from None
create_date = datetime.datetime.now(
tz=datetime.timezone.utc
).astimezone(tzinfo)
# Fall back to uppercase
tzinfo = tz.gettz(self.timezone.upper())
if tzinfo is None:
raise util.CommandError(
"Can't locate timezone: %s" % self.timezone
)
create_date = (
datetime.datetime.utcnow()
.replace(tzinfo=tz.tzutc())
.astimezone(tzinfo)
)
else:
create_date = datetime.datetime.now()
return create_date
@@ -619,8 +634,7 @@ class ScriptDirectory:
head: Optional[_RevIdType] = None,
splice: Optional[bool] = False,
branch_labels: Optional[_RevIdType] = None,
version_path: Union[str, os.PathLike[str], None] = None,
file_template: Optional[str] = None,
version_path: Optional[str] = None,
depends_on: Optional[_RevIdType] = None,
**kw: Any,
) -> Optional[Script]:
@@ -661,7 +675,7 @@ class ScriptDirectory:
self.revision_map.get_revisions(head),
)
for h in heads:
assert h != "base" # type: ignore[comparison-overlap]
assert h != "base"
if len(set(heads)) != len(heads):
raise util.CommandError("Duplicate head revisions specified")
@@ -673,7 +687,7 @@ class ScriptDirectory:
for head_ in heads:
if head_ is not None:
assert isinstance(head_, Script)
version_path = head_._script_path.parent
version_path = os.path.dirname(head_.path)
break
else:
raise util.CommandError(
@@ -681,19 +695,16 @@ class ScriptDirectory:
"please specify --version-path"
)
else:
version_path = self._singular_version_location
else:
version_path = Path(version_path)
version_path = self.versions
assert isinstance(version_path, Path)
norm_path = version_path.absolute()
norm_path = os.path.normpath(os.path.abspath(version_path))
for vers_path in self._version_locations:
if vers_path.absolute() == norm_path:
if os.path.normpath(vers_path) == norm_path:
break
else:
raise util.CommandError(
f"Path {version_path} is not represented in current "
"version locations"
"Path %s is not represented in current "
"version locations" % version_path
)
if self.version_locations:
@@ -714,11 +725,9 @@ class ScriptDirectory:
if depends_on:
with self._catch_revision_errors():
resolved_depends_on = [
(
dep
if dep in rev.branch_labels # maintain branch labels
else rev.revision
) # resolve partial revision identifiers
dep
if dep in rev.branch_labels # maintain branch labels
else rev.revision # resolve partial revision identifiers
for rev, dep in [
(not_none(self.revision_map.get_revision(dep)), dep)
for dep in util.to_list(depends_on)
@@ -728,7 +737,7 @@ class ScriptDirectory:
resolved_depends_on = None
self._generate_template(
Path(self.dir, "script.py.mako"),
os.path.join(self.dir, "script.py.mako"),
path,
up_revision=str(revid),
down_revision=revision.tuple_rev_as_scalar(
@@ -742,7 +751,7 @@ class ScriptDirectory:
**kw,
)
post_write_hooks = self.hooks
post_write_hooks = self.hook_config
if post_write_hooks:
write_hooks._run_hooks(path, post_write_hooks)
@@ -765,11 +774,11 @@ class ScriptDirectory:
def _rev_path(
self,
path: Union[str, os.PathLike[str]],
path: str,
rev_id: str,
message: Optional[str],
create_date: datetime.datetime,
) -> Path:
) -> str:
epoch = int(create_date.timestamp())
slug = "_".join(_slug_re.findall(message or "")).lower()
if len(slug) > self.truncate_slug_length:
@@ -788,10 +797,11 @@ class ScriptDirectory:
"second": create_date.second,
}
)
return Path(path) / filename
return os.path.join(path, filename)
class Script(revision.Revision):
"""Represent a single revision file in a ``versions/`` directory.
The :class:`.Script` instance is returned by methods
@@ -799,17 +809,12 @@ class Script(revision.Revision):
"""
def __init__(
self,
module: ModuleType,
rev_id: str,
path: Union[str, os.PathLike[str]],
):
def __init__(self, module: ModuleType, rev_id: str, path: str):
self.module = module
self.path = _preserving_path_as_str(path)
self.path = path
super().__init__(
rev_id,
module.down_revision,
module.down_revision, # type: ignore[attr-defined]
branch_labels=util.to_tuple(
getattr(module, "branch_labels", None), default=()
),
@@ -824,10 +829,6 @@ class Script(revision.Revision):
path: str
"""Filesystem path of the script."""
@property
def _script_path(self) -> Path:
return Path(self.path)
_db_current_indicator: Optional[bool] = None
"""Utility variable which when set will cause string output to indicate
this is a "current" version in some database"""
@@ -846,9 +847,9 @@ class Script(revision.Revision):
if doc:
if hasattr(self.module, "_alembic_source_encoding"):
doc = doc.decode( # type: ignore[attr-defined]
self.module._alembic_source_encoding
self.module._alembic_source_encoding # type: ignore[attr-defined] # noqa
)
return doc.strip()
return doc.strip() # type: ignore[union-attr]
else:
return ""
@@ -888,7 +889,7 @@ class Script(revision.Revision):
)
return entry
def __str__(self) -> str:
def __str__(self):
return "%s -> %s%s%s%s, %s" % (
self._format_down_revision(),
self.revision,
@@ -922,11 +923,9 @@ class Script(revision.Revision):
if head_indicators or tree_indicators:
text += "%s%s%s" % (
" (head)" if self._is_real_head else "",
(
" (effective head)"
if self.is_head and not self._is_real_head
else ""
),
" (effective head)"
if self.is_head and not self._is_real_head
else "",
" (current)" if self._db_current_indicator else "",
)
if tree_indicators:
@@ -960,33 +959,36 @@ class Script(revision.Revision):
return util.format_as_comma(self._versioned_down_revisions)
@classmethod
def _list_py_dir(
cls, scriptdir: ScriptDirectory, path: Path
) -> List[Path]:
def _from_path(
cls, scriptdir: ScriptDirectory, path: str
) -> Optional[Script]:
dir_, filename = os.path.split(path)
return cls._from_filename(scriptdir, dir_, filename)
@classmethod
def _list_py_dir(cls, scriptdir: ScriptDirectory, path: str) -> List[str]:
paths = []
for root, dirs, files in compat.path_walk(path, top_down=True):
if root.name.endswith("__pycache__"):
for root, dirs, files in os.walk(path, topdown=True):
if root.endswith("__pycache__"):
# a special case - we may include these files
# if a `sourceless` option is specified
continue
for filename in sorted(files):
paths.append(root / filename)
paths.append(os.path.join(root, filename))
if scriptdir.sourceless:
# look for __pycache__
py_cache_path = root / "__pycache__"
if py_cache_path.exists():
py_cache_path = os.path.join(root, "__pycache__")
if os.path.exists(py_cache_path):
# add all files from __pycache__ whose filename is not
# already in the names we got from the version directory.
# add as relative paths including __pycache__ token
names = {
Path(filename).name.split(".")[0] for filename in files
}
names = {filename.split(".")[0] for filename in files}
paths.extend(
py_cache_path / pyc
for pyc in py_cache_path.iterdir()
if pyc.name.split(".")[0] not in names
os.path.join(py_cache_path, pyc)
for pyc in os.listdir(py_cache_path)
if pyc.split(".")[0] not in names
)
if not scriptdir.recursive_version_locations:
@@ -1001,13 +1003,9 @@ class Script(revision.Revision):
return paths
@classmethod
def _from_path(
cls, scriptdir: ScriptDirectory, path: Union[str, os.PathLike[str]]
def _from_filename(
cls, scriptdir: ScriptDirectory, dir_: str, filename: str
) -> Optional[Script]:
path = Path(path)
dir_, filename = path.parent, path.name
if scriptdir.sourceless:
py_match = _sourceless_rev_file.match(filename)
else:
@@ -1025,8 +1023,8 @@ class Script(revision.Revision):
is_c = is_o = False
if is_o or is_c:
py_exists = (dir_ / py_filename).exists()
pyc_exists = (dir_ / (py_filename + "c")).exists()
py_exists = os.path.exists(os.path.join(dir_, py_filename))
pyc_exists = os.path.exists(os.path.join(dir_, py_filename + "c"))
# prefer .py over .pyc because we'd like to get the
# source encoding; prefer .pyc over .pyo because we'd like to
@@ -1042,14 +1040,14 @@ class Script(revision.Revision):
m = _legacy_rev.match(filename)
if not m:
raise util.CommandError(
"Could not determine revision id from "
f"filename {filename}. "
"Could not determine revision id from filename %s. "
"Be sure the 'revision' variable is "
"declared inside the script (please see 'Upgrading "
"from Alembic 0.1 to 0.2' in the documentation)."
% filename
)
else:
revision = m.group(1)
else:
revision = module.revision
return Script(module, revision, dir_ / filename)
return Script(module, revision, os.path.join(dir_, filename))