From a39bbc854f8a3ca0a460945be386ef5921e7cf90 Mon Sep 17 00:00:00 2001 From: Rizhong Lin <59563695+RizhongLin@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:37:02 +0200 Subject: [PATCH] feat: expose the parsed migration view Tooling built around the chain currently has to re-implement parsing and ordering, or reach into private helpers, to answer questions the library already answers internally: which migrations are dynamic, what order git puts them in, and what the generated artifact is called. Export the pieces that answer those questions without building a chain: MigrationFile, parse_versions_dir() and CHAIN_FILENAME (promoted from _CHAIN_FILENAME, whose only two references were internal). parse_versions_dir() returns raw git add order, which is deliberately not the order the built chain walks, since a hybrid is re-parented to sit after the revision it hardcodes. It also does not fall back to a chain file the way build_chain does: that file records only the mapping, not classification or ordering, so there would be nothing to reconstruct from. Both are documented, along with git_sequence being a position within one parse rather than a property of the file. --- README.md | 26 ++++++ alembic_git_revisions/__init__.py | 9 ++ alembic_git_revisions/_chain.py | 48 +++++++++- tests/test_chain.py | 144 ++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5d2a43a..0173e0e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/alembic_git_revisions/__init__.py b/alembic_git_revisions/__init__.py index 3a5137f..9eb5c24 100644 --- a/alembic_git_revisions/__init__.py +++ b/alembic_git_revisions/__init__.py @@ -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, ) @@ -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: diff --git a/alembic_git_revisions/_chain.py b/alembic_git_revisions/_chain.py index e2bb22a..a1d55f0 100644 --- a/alembic_git_revisions/_chain.py +++ b/alembic_git_revisions/_chain.py @@ -11,7 +11,7 @@ _REVISION_FROM_FILENAME_RE = re.compile(r"^([a-f0-9]+)_") -_CHAIN_FILENAME = "revision_chain.json" +CHAIN_FILENAME = "revision_chain.json" @dataclasses.dataclass(frozen=True) @@ -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 @@ -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], @@ -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) @@ -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") diff --git a/tests/test_chain.py b/tests/test_chain.py index 86a1784..793b159 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -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()