django-sync-migrations 0.1.0__tar.gz

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.
@@ -0,0 +1,26 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*" # e.g. v0.1.0
7
+
8
+ jobs:
9
+ run:
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ permissions:
14
+ id-token: write
15
+ contents: read
16
+ steps:
17
+ - name: Checkout
18
+ uses: actions/checkout@v4
19
+ - name: Install uv
20
+ uses: astral-sh/setup-uv@v5
21
+ - name: Install Python
22
+ run: uv python install 3.13
23
+ - name: Build
24
+ run: uv build
25
+ - name: Publish
26
+ run: uv publish
@@ -0,0 +1,3 @@
1
+ .DS_Store
2
+ .venv/
3
+ __pycache__/
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-sync-migrations
3
+ Version: 0.1.0
4
+ Summary: Sync Django migrations to match the dev branch, then checkout dev.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # django-sync-migrations
9
+
10
+ Sync Django migrations to match the dev branch, then checkout dev. Use when on a feature branch with migrations you want to discard before switching back to dev.
11
+
12
+ ## Install / run (from your Django project directory)
13
+
14
+ **If published on PyPI:**
15
+
16
+ ```bash
17
+ uvx django-sync-migrations
18
+ uvx django-sync-migrations --dry-run
19
+ uvx django-sync-migrations --branch develop
20
+ uvx django-sync-migrations --skip-checkout
21
+ ```
22
+
23
+ **Or run from GitHub (no PyPI):**
24
+
25
+ ```bash
26
+ uvx --from git+https://github.com/sponrad/django-sync-migrations django-sync-migrations
27
+ uvx --from git+https://github.com/sponrad/django-sync-migrations@main django-sync-migrations # pin to branch/tag
28
+ ```
29
+
30
+ Requires a Django project (with `manage.py`), git, and a reachable database. The tool runs `manage.py migrate` using your project’s `.venv` Python when present.
31
+
32
+ ---
33
+
34
+ ## Publishing to PyPI
35
+
36
+ ### One-time setup
37
+
38
+ 1. **Create a PyPI account** at [pypi.org](https://pypi.org) and enable 2FA.
39
+ 2. **Create the project on PyPI** (or it will be created on first publish): go to [pypi.org/manage/account/](https://pypi.org/manage/account/) → “Create a new project”, name it `django-sync-migrations`.
40
+ 3. **Create an API token** (Account → API tokens): scope it to this project (or “Entire account” for testing). Copy the token; you won’t see it again.
41
+
42
+ ### Manual publish (each release)
43
+
44
+ From the repo root:
45
+
46
+ ```bash
47
+ # Bump version (optional; or edit pyproject.toml by hand)
48
+ uv version --bump patch
49
+
50
+ # Build
51
+ uv build
52
+
53
+ # Upload to PyPI (set token once: export UV_PUBLISH_TOKEN=pypi-...)
54
+ uv publish --token $(grep UV_PUBLISH_TOKEN .env 2>/dev/null | cut -d= -f2)
55
+ # or: uv publish # if UV_PUBLISH_TOKEN is already in the environment
56
+ ```
57
+
58
+ For **Test PyPI** first: add to `pyproject.toml` under `[tool.uv]` (or use a separate config) an index with `publish-url = "https://test.pypi.org/legacy/"` and run `uv publish --index testpypi`.
59
+
60
+ ### Automated publish (recommended): Trusted Publisher
61
+
62
+ No tokens: push a tag and the workflow publishes to PyPI.
63
+
64
+ 1. **GitHub:** Create an environment named `pypi` (Settings → Environments).
65
+ 2. **PyPI:** Project → “Publishing” → “Add a new trusted publisher” → GitHub. Set:
66
+ - **Owner/repository:** `sponrad/django-sync-migrations`
67
+ - **Workflow name:** `publish.yml`
68
+ - **Environment name:** `pypi`
69
+ 3. **Release:** Tag and push. The workflow in `.github/workflows/publish.yml` runs on tags `v*` (e.g. `v0.1.0`).
70
+
71
+ ```bash
72
+ git tag -a v0.1.0 -m "Release 0.1.0"
73
+ git push --tags
74
+ ```
75
+
76
+ [PyPI: Trusted publishers](https://docs.pypi.org/trusted-publishers/).
@@ -0,0 +1,69 @@
1
+ # django-sync-migrations
2
+
3
+ Sync Django migrations to match the dev branch, then checkout dev. Use when on a feature branch with migrations you want to discard before switching back to dev.
4
+
5
+ ## Install / run (from your Django project directory)
6
+
7
+ **If published on PyPI:**
8
+
9
+ ```bash
10
+ uvx django-sync-migrations
11
+ uvx django-sync-migrations --dry-run
12
+ uvx django-sync-migrations --branch develop
13
+ uvx django-sync-migrations --skip-checkout
14
+ ```
15
+
16
+ **Or run from GitHub (no PyPI):**
17
+
18
+ ```bash
19
+ uvx --from git+https://github.com/sponrad/django-sync-migrations django-sync-migrations
20
+ uvx --from git+https://github.com/sponrad/django-sync-migrations@main django-sync-migrations # pin to branch/tag
21
+ ```
22
+
23
+ Requires a Django project (with `manage.py`), git, and a reachable database. The tool runs `manage.py migrate` using your project’s `.venv` Python when present.
24
+
25
+ ---
26
+
27
+ ## Publishing to PyPI
28
+
29
+ ### One-time setup
30
+
31
+ 1. **Create a PyPI account** at [pypi.org](https://pypi.org) and enable 2FA.
32
+ 2. **Create the project on PyPI** (or it will be created on first publish): go to [pypi.org/manage/account/](https://pypi.org/manage/account/) → “Create a new project”, name it `django-sync-migrations`.
33
+ 3. **Create an API token** (Account → API tokens): scope it to this project (or “Entire account” for testing). Copy the token; you won’t see it again.
34
+
35
+ ### Manual publish (each release)
36
+
37
+ From the repo root:
38
+
39
+ ```bash
40
+ # Bump version (optional; or edit pyproject.toml by hand)
41
+ uv version --bump patch
42
+
43
+ # Build
44
+ uv build
45
+
46
+ # Upload to PyPI (set token once: export UV_PUBLISH_TOKEN=pypi-...)
47
+ uv publish --token $(grep UV_PUBLISH_TOKEN .env 2>/dev/null | cut -d= -f2)
48
+ # or: uv publish # if UV_PUBLISH_TOKEN is already in the environment
49
+ ```
50
+
51
+ For **Test PyPI** first: add to `pyproject.toml` under `[tool.uv]` (or use a separate config) an index with `publish-url = "https://test.pypi.org/legacy/"` and run `uv publish --index testpypi`.
52
+
53
+ ### Automated publish (recommended): Trusted Publisher
54
+
55
+ No tokens: push a tag and the workflow publishes to PyPI.
56
+
57
+ 1. **GitHub:** Create an environment named `pypi` (Settings → Environments).
58
+ 2. **PyPI:** Project → “Publishing” → “Add a new trusted publisher” → GitHub. Set:
59
+ - **Owner/repository:** `sponrad/django-sync-migrations`
60
+ - **Workflow name:** `publish.yml`
61
+ - **Environment name:** `pypi`
62
+ 3. **Release:** Tag and push. The workflow in `.github/workflows/publish.yml` runs on tags `v*` (e.g. `v0.1.0`).
63
+
64
+ ```bash
65
+ git tag -a v0.1.0 -m "Release 0.1.0"
66
+ git push --tags
67
+ ```
68
+
69
+ [PyPI: Trusted publishers](https://docs.pypi.org/trusted-publishers/).
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Sync Django migrations to match the dev branch, then checkout dev.
4
+
5
+ Use when on a feature branch with migrations you want to discard before
6
+ switching back to dev. Rolls back the database to the migration state on dev,
7
+ then checks out the dev branch.
8
+
9
+ Usage (from your Django project directory; run from PyPI or GitHub):
10
+ uvx django-sync-migrations
11
+ uvx django-sync-migrations --dry-run
12
+ uvx django-sync-migrations --branch develop
13
+ uvx django-sync-migrations --skip-checkout
14
+
15
+ From GitHub without PyPI:
16
+ uvx --from git+https://github.com/sponrad/django-sync-migrations django-sync-migrations
17
+
18
+ Requires Django and a reachable database for the migrate step. When run via uvx,
19
+ manage.py is executed with your project's .venv Python if present, otherwise
20
+ the current interpreter.
21
+ """
22
+
23
+ import argparse
24
+ import importlib
25
+ import os
26
+ import re
27
+ import subprocess
28
+ import sys
29
+ from pathlib import Path
30
+
31
+
32
+ def git_cmd(args: list[str], cwd: Path = None) -> str | None:
33
+ """Run git command and return stdout, or None on error."""
34
+ try:
35
+ result = subprocess.run(
36
+ ["git"] + args,
37
+ capture_output=True,
38
+ text=True,
39
+ check=True,
40
+ cwd=cwd,
41
+ )
42
+ return result.stdout.strip()
43
+ except (subprocess.CalledProcessError, FileNotFoundError):
44
+ return None
45
+
46
+
47
+ def find_project_root() -> Path | None:
48
+ """Walk up from cwd to find directory containing manage.py."""
49
+ for parent in [Path.cwd().resolve(), *Path.cwd().resolve().parents]:
50
+ if (parent / "manage.py").exists():
51
+ return parent
52
+ return None
53
+
54
+
55
+ def get_manage_py_python(project_root: Path) -> str:
56
+ """
57
+ Return the Python executable to use for running manage.py.
58
+ Prefer the project's .venv so that Django and project deps are available
59
+ when this tool is run via uvx (which uses an isolated env without Django).
60
+ """
61
+ venv_python = project_root / ".venv" / "bin" / "python"
62
+ if venv_python.exists():
63
+ return str(venv_python)
64
+ # Windows
65
+ venv_python_win = project_root / ".venv" / "Scripts" / "python.exe"
66
+ if venv_python_win.exists():
67
+ return str(venv_python_win)
68
+ return sys.executable
69
+
70
+
71
+ def get_installed_app_labels(project_root: Path) -> frozenset[str] | None:
72
+ """
73
+ Load Django settings and extract app labels from INSTALLED_APPS.
74
+ Returns None if settings can't be loaded.
75
+ """
76
+ manage_py = project_root / "manage.py"
77
+ if not manage_py.exists():
78
+ return None
79
+
80
+ # Resolve DJANGO_SETTINGS_MODULE from manage.py
81
+ content = manage_py.read_text()
82
+ match = re.search(
83
+ r"setdefault\s*\(\s*['\"]DJANGO_SETTINGS_MODULE['\"]\s*,\s*['\"]([^'\"]+)['\"]",
84
+ content,
85
+ )
86
+ if not match:
87
+ return None
88
+
89
+ settings_module_name = match.group(1)
90
+ project_root_str = str(project_root.resolve())
91
+ if project_root_str not in sys.path:
92
+ sys.path.insert(0, project_root_str)
93
+
94
+ try:
95
+ settings_module = importlib.import_module(settings_module_name)
96
+ except Exception:
97
+ return None
98
+
99
+ labels = set()
100
+ for app in getattr(settings_module, "INSTALLED_APPS", []):
101
+ if isinstance(app, str):
102
+ app_path = app
103
+ else:
104
+ app_path = getattr(app, "label", None) or getattr(app, "name", "")
105
+ if not app_path:
106
+ continue
107
+ labels.add(app_path)
108
+ labels.add(app_path.replace(".", "/"))
109
+ parts = app_path.split(".")
110
+ if parts:
111
+ labels.add(parts[0])
112
+ labels.add(parts[-1])
113
+ return frozenset(labels) if labels else None
114
+
115
+
116
+ def get_migration_targets(
117
+ branch: str, repo_root: Path, allowed_labels: frozenset[str] | None
118
+ ) -> list[tuple[str, str]]:
119
+ """
120
+ Find latest migration for each app on the target branch.
121
+ Returns [(app_label, migration_name), ...] sorted by app_label.
122
+ """
123
+ output = git_cmd(["ls-tree", "-r", branch, "--name-only"], cwd=repo_root)
124
+ if not output:
125
+ return []
126
+
127
+ # Group migrations by app: {app_dir: [(number, full_name), ...]}
128
+ app_migrations = {}
129
+ pattern = re.compile(r"^(.+)/migrations/(\d+)_(.+\.py)$")
130
+
131
+ for line in output.splitlines():
132
+ if "/.venv/" in line or "/site-packages/" in line:
133
+ continue
134
+
135
+ if match := pattern.match(line.strip()):
136
+ app_dir, num_str, name_part = match.groups()
137
+
138
+ # Filter by allowed labels if specified
139
+ if allowed_labels and app_dir not in allowed_labels:
140
+ continue
141
+
142
+ migration_name = f"{num_str}_{name_part[:-3]}" # Remove .py
143
+ app_migrations.setdefault(app_dir, []).append((int(num_str), migration_name))
144
+
145
+ # Return the latest migration for each app
146
+ return [
147
+ (app, max(migrations, key=lambda x: x[0])[1])
148
+ for app, migrations in sorted(app_migrations.items())
149
+ ]
150
+
151
+
152
+ def run_migrate(
153
+ project_root: Path, app: str, migration: str, dry_run: bool
154
+ ) -> bool:
155
+ """Run django migration command."""
156
+ python_exe = get_manage_py_python(project_root)
157
+ cmd = [
158
+ python_exe,
159
+ str(project_root / "manage.py"),
160
+ "migrate",
161
+ app,
162
+ migration,
163
+ "--noinput",
164
+ ]
165
+
166
+ if dry_run:
167
+ print(f" [dry-run] would run: {' '.join(cmd)}")
168
+ return True
169
+
170
+ try:
171
+ subprocess.run(cmd, cwd=project_root, check=True)
172
+ return True
173
+ except subprocess.CalledProcessError as e:
174
+ print(f" ERROR: migrate failed: {e}", file=sys.stderr)
175
+ return False
176
+
177
+
178
+ def main() -> int:
179
+ parser = argparse.ArgumentParser(
180
+ description="Reset migrations to dev branch state, then checkout dev.",
181
+ formatter_class=argparse.RawDescriptionHelpFormatter,
182
+ epilog=__doc__,
183
+ )
184
+ parser.add_argument(
185
+ "--branch",
186
+ "-b",
187
+ default=os.environ.get("DEV_BRANCH", "dev"),
188
+ help="Target branch (default: dev or DEV_BRANCH env)",
189
+ )
190
+ parser.add_argument(
191
+ "--dry-run",
192
+ action="store_true",
193
+ help="Print what would be done without executing",
194
+ )
195
+ parser.add_argument(
196
+ "--skip-checkout",
197
+ action="store_true",
198
+ help="Only reset migrations, do not checkout branch",
199
+ )
200
+ parser.add_argument(
201
+ "--force",
202
+ action="store_true",
203
+ help="Run migration reset even when already on target branch",
204
+ )
205
+ args = parser.parse_args()
206
+
207
+ # Validate environment
208
+ project_root = find_project_root()
209
+ if not project_root:
210
+ print(
211
+ "ERROR: Could not find manage.py. Run from project root or subdirectory.",
212
+ file=sys.stderr,
213
+ )
214
+ return 1
215
+
216
+ repo_root = Path(git_cmd(["rev-parse", "--show-toplevel"], cwd=project_root) or "")
217
+ if not repo_root or not repo_root.exists():
218
+ print("ERROR: Not a git repository.", file=sys.stderr)
219
+ return 1
220
+
221
+ if not git_cmd(["rev-parse", "--verify", args.branch]):
222
+ print(f"ERROR: Branch '{args.branch}' does not exist.", file=sys.stderr)
223
+ return 1
224
+
225
+ current_branch = git_cmd(["rev-parse", "--abbrev-ref", "HEAD"])
226
+ if current_branch == args.branch and not args.force:
227
+ print(
228
+ f"Already on {args.branch}. Nothing to do. Use --force to reset anyway."
229
+ )
230
+ return 0
231
+
232
+ # Display context
233
+ print(f"Project root: {project_root}")
234
+ print(f"Current branch: {current_branch}")
235
+ print(f"Target branch: {args.branch}")
236
+ if args.dry_run:
237
+ print("(dry run - no changes will be made)")
238
+ print()
239
+
240
+ # Find migrations to reset
241
+ allowed_labels = get_installed_app_labels(project_root)
242
+ if not allowed_labels:
243
+ print("WARNING: Could not parse INSTALLED_APPS. Including all repo apps.")
244
+
245
+ targets = get_migration_targets(args.branch, repo_root, allowed_labels)
246
+
247
+ if not targets:
248
+ print(f"No migrations found on {args.branch}. Nothing to reset.")
249
+ else:
250
+ print(f"Resetting migrations to match {args.branch}:")
251
+ for app, migration in targets:
252
+ print(f" {app} -> {migration}")
253
+ print()
254
+
255
+ for app, migration in targets:
256
+ if not run_migrate(project_root, app, migration, args.dry_run):
257
+ return 1
258
+
259
+ # Checkout target branch
260
+ if args.skip_checkout:
261
+ print("Skipping checkout (--skip-checkout).")
262
+ elif args.dry_run:
263
+ print(f"\nWould run: git checkout {args.branch}")
264
+ else:
265
+ print(f"\nChecking out {args.branch}...")
266
+ try:
267
+ subprocess.run(
268
+ ["git", "checkout", args.branch], check=True, cwd=repo_root
269
+ )
270
+ print("Done.")
271
+ except subprocess.CalledProcessError as e:
272
+ print(f"ERROR: git checkout failed: {e}", file=sys.stderr)
273
+ return 1
274
+
275
+ return 0
276
+
277
+
278
+ if __name__ == "__main__":
279
+ sys.exit(main())
@@ -0,0 +1,13 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "django-sync-migrations"
7
+ version = "0.1.0"
8
+ description = "Sync Django migrations to match the dev branch, then checkout dev."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+
12
+ [project.scripts]
13
+ django-sync-migrations = "django_sync_migrations:main"
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 1
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "django-sync-migrations"
7
+ version = "0.1.0"
8
+ source = { virtual = "." }