Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,32 @@ Generates `revision_chain.json` from git history. Run this before building Docke

Returns the full `{revision: down_revision}` dict. Cached per `versions_dir`. Use `build_chain.cache_clear()` to reset in tests.

### `parse_versions_dir(versions_dir)`

Returns the migrations in `versions_dir` as a list of `MigrationFile`, so tooling can inspect classification and ordering without building a chain.

The order is the raw order files were added to git. It is **not** the order the chain walks: `build_chain` re-parents a hybrid to sit immediately after the revision it hardcodes, which can move it far from its own add position. Use `build_chain` when you want traversal order.

Unlike `build_chain`, this never falls back to `revision_chain.json`, because that file records only `{revision: down_revision}` and carries neither classification nor ordering. Git is required, and its absence raises `RuntimeError` rather than returning a plausible wrong order. Results are not cached.

### `MigrationFile`

A frozen dataclass describing one parsed migration:

| Field | Meaning |
|---|---|
| `revision` | the revision id, read from the module's `revision` attribute |
| `filename` | the file's basename |
| `git_sequence` | position within the parse that produced it (see below) |
| `is_dynamic` | whether `down_revision` calls `get_down_revision()` |
| `static_down_revisions` | hardcoded parents; more than one means a merge migration |

`git_sequence` is a position within one particular parse, not a stable property of the file. Files absent from git history all share the same end-of-list sentinel and are separated only by `filename`, which is why `parse_versions_dir` sorts on both.

### `CHAIN_FILENAME`

Name of the generated chain file, `revision_chain.json`. Use it instead of hardcoding the string when locating or cleaning up the generated artifact.

## License

Apache-2.0
9 changes: 9 additions & 0 deletions alembic_git_revisions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
import pathlib
import sys

from alembic_git_revisions._chain import (
CHAIN_FILENAME as CHAIN_FILENAME,
)
from alembic_git_revisions._chain import (
MigrationFile as MigrationFile,
)
from alembic_git_revisions._chain import (
build_chain as build_chain,
)
Expand All @@ -29,6 +35,9 @@
from alembic_git_revisions._chain import (
get_down_revision as get_down_revision,
)
from alembic_git_revisions._chain import (
parse_versions_dir as parse_versions_dir,
)


def _cli() -> None:
Expand Down
48 changes: 45 additions & 3 deletions alembic_git_revisions/_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

_REVISION_FROM_FILENAME_RE = re.compile(r"^([a-f0-9]+)_")

_CHAIN_FILENAME = "revision_chain.json"
CHAIN_FILENAME = "revision_chain.json"

Comment thread
RizhongLin marked this conversation as resolved.

@dataclasses.dataclass(frozen=True)
Expand All @@ -29,6 +29,15 @@ class MigrationFile:
*dynamic* revision. It already has a hardcoded predecessor but
must participate in the dynamic ordering so that subsequent dynamic
files chain after it (not after the dynamic revision it points to).

``git_sequence`` is a position within one particular parse, not a
stable property of the file: it indexes the ``git_order`` list that
produced this instance. Every file missing from that list (typically
because it is not committed yet) receives the same end-of-list
sentinel, so those files tie and are separated only by sorting on
``filename`` as well. Construct instances through
:func:`parse_versions_dir` rather than calling ``from_file``
directly, which would require inventing a value for it.
"""

revision: str
Expand Down Expand Up @@ -286,6 +295,39 @@ def _extract_revision(filename: str) -> str:
return m.group(1)


def parse_versions_dir(versions_dir: pathlib.Path) -> list[MigrationFile]:
"""Parse and classify every migration in *versions_dir*, in git add order.

Exposes the same view the chain builder works from, for callers that
want to inspect classification or ordering without building a chain.

The order is the raw order files were added to git, sorted by
``(git_sequence, filename)``. It is **not** the order the resulting
chain walks: :func:`build_chain` re-parents a hybrid to sit
immediately after the revision it hardcodes, which can move it far
from its own add position. Use :func:`build_chain` when the question
is what Alembic will traverse.

Unlike :func:`build_chain` this never falls back to a generated chain
file, because that file records only ``{revision: down_revision}``;
it carries neither the classification nor the ordering this returns,
so there is nothing to reconstruct from. Git is therefore required,
and its absence raises rather than yielding a plausible wrong order.

Results are not cached, so each call re-reads git and re-parses every
file and will pick up edits made since the last call.
"""
git_order = _get_git_commit_order(versions_dir)
if git_order is None:
msg = (
f"Cannot read git history for {versions_dir}: git is not available "
f"or this is a shallow clone."
)
raise RuntimeError(msg)
files = _parse_migration_files(versions_dir, git_order)
return sorted(files, key=lambda f: (f.git_sequence, f.filename))


def _parse_migration_files(
versions_dir: pathlib.Path,
git_order: list[str],
Expand Down Expand Up @@ -434,7 +476,7 @@ def build_chain(versions_dir: pathlib.Path) -> dict[str, str]:
The result is cached per *versions_dir*. Use
``build_chain.cache_clear()`` to reset (e.g. in tests).
"""
chain_file = versions_dir.parent / _CHAIN_FILENAME
chain_file = versions_dir.parent / CHAIN_FILENAME
if chain_file.exists():
return _load_chain_from_file(chain_file)
chain = _build_chain_from_git(versions_dir)
Expand Down Expand Up @@ -484,7 +526,7 @@ def generate_chain_file(versions_dir: pathlib.Path) -> None:
"git is not available or this is a shallow clone."
)
raise RuntimeError(msg)
chain_file = versions_dir.parent / _CHAIN_FILENAME
chain_file = versions_dir.parent / CHAIN_FILENAME
with chain_file.open("w", encoding="utf-8") as f:
json.dump(chain, f, indent=2, sort_keys=True)
f.write("\n")
Expand Down
144 changes: 144 additions & 0 deletions tests/test_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1215,3 +1215,147 @@ def add(revision: str, filename: str) -> None:
_git(repo, "merge", "--no-ff", "-m", "Merge pull request from main", "main")

assert _chain._get_git_commit_order(versions) == expected


def test_parse_versions_dir_classifies_in_git_order(tmp_path: pathlib.Path) -> None:
"""The parsed view carries classification and git order, not a chain."""
versions_dir = tmp_path / "versions"
versions_dir.mkdir()

(versions_dir / "aaaa_root.py").write_text(
'revision = "aaaa"\ndown_revision = None\n',
)
(versions_dir / "bbbb_dynamic.py").write_text(
"from alembic_git_revisions import get_down_revision\n"
'revision = "bbbb"\n'
"down_revision = get_down_revision(revision)\n",
)
(versions_dir / "cccc_manual.py").write_text(
'revision = "cccc"\ndown_revision = "bbbb"\n',
)

# Deliberately not alphabetical, to show the order comes from git.
git_order = ["aaaa_root.py", "cccc_manual.py", "bbbb_dynamic.py"]

with mock.patch.object(
_chain,
"_get_git_commit_order",
return_value=git_order,
):
files = _chain.parse_versions_dir(versions_dir)

assert [f.revision for f in files] == ["aaaa", "cccc", "bbbb"]
assert [f.is_dynamic for f in files] == [False, False, True]
assert files[1].static_down_revisions == ("bbbb",)
assert files[0].static_down_revisions == ()


def test_parse_versions_dir_places_uncommitted_files_last(
tmp_path: pathlib.Path,
) -> None:
"""Files absent from git history sort last, tie-broken by filename.

They all share the same end-of-list sentinel, so ``filename`` is the
only thing separating them and the order must not depend on the
filesystem's glob order.
"""
versions_dir = tmp_path / "versions"
versions_dir.mkdir()

(versions_dir / "aaaa_root.py").write_text(
'revision = "aaaa"\ndown_revision = None\n',
)
# Written in reverse of their filename order, and neither is committed.
for revision, name in (("dddd", "zzz_new"), ("cccc", "mmm_new")):
(versions_dir / f"{name}.py").write_text(
"from alembic_git_revisions import get_down_revision\n"
f'revision = "{revision}"\n'
"down_revision = get_down_revision(revision)\n",
)

with mock.patch.object(
_chain,
"_get_git_commit_order",
return_value=["aaaa_root.py"],
):
files = _chain.parse_versions_dir(versions_dir)

assert [f.revision for f in files] == ["aaaa", "cccc", "dddd"]
assert files[1].git_sequence == files[2].git_sequence


def test_parse_versions_dir_order_is_add_order_not_chain_order(
tmp_path: pathlib.Path,
) -> None:
"""A hybrid keeps its add position here, but moves in the built chain.

``build_chain`` re-parents a hybrid to sit right after the revision it
hardcodes; ``parse_versions_dir`` reports raw add order and does not.
Callers must not treat one as the other.
"""
versions_dir = tmp_path / "versions"
versions_dir.mkdir()

(versions_dir / "aaaa_root.py").write_text(
'revision = "aaaa"\ndown_revision = None\n',
)
for revision, name in (("bbbb", "one"), ("cccc", "two")):
(versions_dir / f"{revision}_{name}.py").write_text(
"from alembic_git_revisions import get_down_revision\n"
f'revision = "{revision}"\n'
"down_revision = get_down_revision(revision)\n",
)
# Added last, but hardcoded onto bbbb, so the chain pulls it earlier.
(versions_dir / "dddd_hybrid.py").write_text(
'revision = "dddd"\ndown_revision = "bbbb"\n',
)

git_order = [
"aaaa_root.py",
"bbbb_one.py",
"cccc_two.py",
"dddd_hybrid.py",
]

with mock.patch.object(
_chain,
"_get_git_commit_order",
return_value=git_order,
):
files = _chain.parse_versions_dir(versions_dir)
chain = _chain._build_chain_from_git(versions_dir)

# Add order: the hybrid is last.
assert [f.revision for f in files] == ["aaaa", "bbbb", "cccc", "dddd"]
# Chain order: cccc now follows the hybrid, so dddd precedes it.
assert chain["cccc"] == "dddd"


def test_parse_versions_dir_without_git(tmp_path: pathlib.Path) -> None:
"""Ordering cannot be guessed, so a missing git history is an error."""
versions_dir = tmp_path / "versions"
versions_dir.mkdir()

with (
mock.patch.object(_chain, "_get_git_commit_order", return_value=None),
pytest.raises(RuntimeError, match="Cannot read git history"),
):
_chain.parse_versions_dir(versions_dir)


def test_chain_filename_is_the_generated_file(tmp_path: pathlib.Path) -> None:
"""The public constant names the file generate_chain_file writes."""
versions_dir = tmp_path / "versions"
versions_dir.mkdir()
(versions_dir / "aaaa_root.py").write_text(
'revision = "aaaa"\ndown_revision = None\n',
)

with mock.patch.object(
_chain,
"_get_git_commit_order",
return_value=["aaaa_root.py"],
):
_chain.generate_chain_file(versions_dir)

assert (versions_dir.parent / _chain.CHAIN_FILENAME).is_file()
Loading