pgmini-migrate 0.1.1__tar.gz → 0.1.2__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.
- pgmini_migrate-0.1.2/PKG-INFO +40 -0
- pgmini_migrate-0.1.2/README.md +24 -0
- pgmini_migrate-0.1.2/pgmini_migrate/__init__.py +1 -0
- pgmini_migrate-0.1.2/pgmini_migrate/__main__.py +4 -0
- pgmini_migrate-0.1.2/pgmini_migrate/cli.py +340 -0
- pgmini_migrate-0.1.2/pgmini_migrate.egg-info/PKG-INFO +40 -0
- {pgmini_migrate-0.1.1 → pgmini_migrate-0.1.2}/pgmini_migrate.egg-info/SOURCES.txt +2 -2
- {pgmini_migrate-0.1.1 → pgmini_migrate-0.1.2}/pgmini_migrate.egg-info/requires.txt +1 -0
- {pgmini_migrate-0.1.1 → pgmini_migrate-0.1.2}/pyproject.toml +4 -3
- {pgmini_migrate-0.1.1 → pgmini_migrate-0.1.2}/setup.py +1 -1
- pgmini_migrate-0.1.1/PKG-INFO +0 -22
- pgmini_migrate-0.1.1/README.md +0 -7
- pgmini_migrate-0.1.1/pgmini_migrate/cli.py +0 -30
- pgmini_migrate-0.1.1/pgmini_migrate/generator.py +0 -37
- pgmini_migrate-0.1.1/pgmini_migrate/runner.py +0 -156
- pgmini_migrate-0.1.1/pgmini_migrate.egg-info/PKG-INFO +0 -22
- {pgmini_migrate-0.1.1 → pgmini_migrate-0.1.2}/LICENSE +0 -0
- /pgmini_migrate-0.1.1/pgmini_migrate/__init__.py → /pgmini_migrate-0.1.2/pgmini_migrate/utils.py +0 -0
- {pgmini_migrate-0.1.1 → pgmini_migrate-0.1.2}/pgmini_migrate.egg-info/dependency_links.txt +0 -0
- {pgmini_migrate-0.1.1 → pgmini_migrate-0.1.2}/pgmini_migrate.egg-info/entry_points.txt +0 -0
- {pgmini_migrate-0.1.1 → pgmini_migrate-0.1.2}/pgmini_migrate.egg-info/top_level.txt +0 -0
- {pgmini_migrate-0.1.1 → pgmini_migrate-0.1.2}/setup.cfg +0 -0
@@ -0,0 +1,40 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: pgmini-migrate
|
3
|
+
Version: 0.1.2
|
4
|
+
Summary: A minimal PostgreSQL migration tool for Python
|
5
|
+
Author-email: Nguyen Vu Duy Luan <nvdluan@gmail.com>
|
6
|
+
License: Copyright (c) 2025 Nguyen Vu Duy Luan
|
7
|
+
Project-URL: Homepage, https://github.com/NVDLuan/pgmini-migrate
|
8
|
+
Project-URL: Issues, https://github.com/NVDLuan/pgmini-migrate/issues
|
9
|
+
Requires-Python: >=3.10
|
10
|
+
Description-Content-Type: text/markdown
|
11
|
+
License-File: LICENSE
|
12
|
+
Requires-Dist: psycopg[binary]>=3.1.8
|
13
|
+
Requires-Dist: dotenv<0.10.0,>=0.9.9
|
14
|
+
Requires-Dist: click<9.0.0,>=8.2.1
|
15
|
+
Dynamic: license-file
|
16
|
+
|
17
|
+
# pgmini-migrate
|
18
|
+
|
19
|
+
`pgmini-migrate` is a **lightweight SQL migration tool** for PostgreSQL.
|
20
|
+
It allows you to easily **create migration files** and run **upgrades/downgrades** using simple CLI commands.
|
21
|
+
|
22
|
+
No ORM required — just pure SQL.
|
23
|
+
|
24
|
+
---
|
25
|
+
|
26
|
+
## ✨ Features
|
27
|
+
- Auto-generate migration files (`upgrade` + `downgrade`).
|
28
|
+
- Run **upgrade** or **downgrade** up to a specific version.
|
29
|
+
- Two connection options:
|
30
|
+
- via `DATABASE_URL`
|
31
|
+
- via `host`, `port`, `user`, `password`, `dbname`.
|
32
|
+
- Configurable migrations directory.
|
33
|
+
- Minimal, simple, and PostgreSQL-focused.
|
34
|
+
|
35
|
+
---
|
36
|
+
|
37
|
+
## 📦 Installation
|
38
|
+
|
39
|
+
```bash
|
40
|
+
pip install pgmini-migrate
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# pgmini-migrate
|
2
|
+
|
3
|
+
`pgmini-migrate` is a **lightweight SQL migration tool** for PostgreSQL.
|
4
|
+
It allows you to easily **create migration files** and run **upgrades/downgrades** using simple CLI commands.
|
5
|
+
|
6
|
+
No ORM required — just pure SQL.
|
7
|
+
|
8
|
+
---
|
9
|
+
|
10
|
+
## ✨ Features
|
11
|
+
- Auto-generate migration files (`upgrade` + `downgrade`).
|
12
|
+
- Run **upgrade** or **downgrade** up to a specific version.
|
13
|
+
- Two connection options:
|
14
|
+
- via `DATABASE_URL`
|
15
|
+
- via `host`, `port`, `user`, `password`, `dbname`.
|
16
|
+
- Configurable migrations directory.
|
17
|
+
- Minimal, simple, and PostgreSQL-focused.
|
18
|
+
|
19
|
+
---
|
20
|
+
|
21
|
+
## 📦 Installation
|
22
|
+
|
23
|
+
```bash
|
24
|
+
pip install pgmini-migrate
|
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = "0.1.0"
|
@@ -0,0 +1,340 @@
|
|
1
|
+
import os
|
2
|
+
import sys
|
3
|
+
import psycopg
|
4
|
+
from contextlib import contextmanager
|
5
|
+
from datetime import datetime
|
6
|
+
import argparse
|
7
|
+
|
8
|
+
# Default layout
|
9
|
+
DEFAULT_MIGRATIONS_DIR = "migrations"
|
10
|
+
UPGRADE_SUBDIR = "upgrade"
|
11
|
+
DOWNGRADE_SUBDIR = "downgrade"
|
12
|
+
MIGRATION_TABLE = "schema_migrations"
|
13
|
+
|
14
|
+
|
15
|
+
def load_env_file(env_path=".env"):
|
16
|
+
"""Load simple env file KEY=VALUE into os.environ (if not already set)."""
|
17
|
+
if not os.path.exists(env_path):
|
18
|
+
return
|
19
|
+
with open(env_path, "r") as f:
|
20
|
+
for ln in f:
|
21
|
+
ln = ln.strip()
|
22
|
+
if not ln or ln.startswith("#"):
|
23
|
+
continue
|
24
|
+
if "=" not in ln:
|
25
|
+
continue
|
26
|
+
k, v = ln.split("=", 1)
|
27
|
+
k = k.strip()
|
28
|
+
v = v.strip().strip('"').strip("'")
|
29
|
+
# do not overwrite existing env set by host
|
30
|
+
if k not in os.environ:
|
31
|
+
os.environ[k] = v
|
32
|
+
|
33
|
+
|
34
|
+
def build_conninfo_from_args(args):
|
35
|
+
"""Return a psycopg-compatible connection string or dict kwargs"""
|
36
|
+
if args.database_url:
|
37
|
+
return args.database_url
|
38
|
+
# build a dict for psycopg.connect(...)
|
39
|
+
kw = {}
|
40
|
+
if args.db_host:
|
41
|
+
kw["host"] = args.db_host
|
42
|
+
if args.db_port:
|
43
|
+
kw["port"] = args.db_port
|
44
|
+
if args.db_name:
|
45
|
+
kw["dbname"] = args.db_name
|
46
|
+
if args.db_user:
|
47
|
+
kw["user"] = args.db_user
|
48
|
+
if args.db_password:
|
49
|
+
kw["password"] = args.db_password
|
50
|
+
return kw
|
51
|
+
|
52
|
+
|
53
|
+
@contextmanager
|
54
|
+
def get_connection(conninfo):
|
55
|
+
"""Context manager returning a connected psycopg connection (sync)."""
|
56
|
+
if isinstance(conninfo, str):
|
57
|
+
conn = psycopg.connect(conninfo)
|
58
|
+
else:
|
59
|
+
conn = psycopg.connect(**conninfo)
|
60
|
+
try:
|
61
|
+
yield conn
|
62
|
+
finally:
|
63
|
+
conn.close()
|
64
|
+
|
65
|
+
|
66
|
+
def ensure_migration_table(conn):
|
67
|
+
with conn.cursor() as cur:
|
68
|
+
cur.execute(
|
69
|
+
f"""
|
70
|
+
CREATE TABLE IF NOT EXISTS {MIGRATION_TABLE} (
|
71
|
+
version VARCHAR(255) PRIMARY KEY,
|
72
|
+
applied_at TIMESTAMP DEFAULT NOW()
|
73
|
+
)
|
74
|
+
"""
|
75
|
+
)
|
76
|
+
conn.commit()
|
77
|
+
|
78
|
+
|
79
|
+
def get_applied_migrations(conn):
|
80
|
+
with conn.cursor() as cur:
|
81
|
+
cur.execute(f"SELECT version FROM {MIGRATION_TABLE} ORDER BY version")
|
82
|
+
rows = cur.fetchall()
|
83
|
+
return [row[0] for row in rows]
|
84
|
+
|
85
|
+
|
86
|
+
def apply_migration(conn, version, sql_path):
|
87
|
+
with open(sql_path, "r", encoding="utf-8") as f:
|
88
|
+
sql = f.read()
|
89
|
+
try:
|
90
|
+
with conn.cursor() as cur:
|
91
|
+
print(f"⬆️ Applying {version} -> {os.path.basename(sql_path)}")
|
92
|
+
cur.execute(sql)
|
93
|
+
cur.execute(
|
94
|
+
f"INSERT INTO {MIGRATION_TABLE} (version) VALUES (%s)", (version,)
|
95
|
+
)
|
96
|
+
conn.commit()
|
97
|
+
print(f"✅ {version} applied.")
|
98
|
+
except Exception as e:
|
99
|
+
conn.rollback()
|
100
|
+
print(f"❌ Failed to apply {version}: {e}")
|
101
|
+
raise
|
102
|
+
|
103
|
+
|
104
|
+
def rollback_migration_from_file(conn, version, sql_path):
|
105
|
+
with open(sql_path, "r", encoding="utf-8") as f:
|
106
|
+
sql = f.read()
|
107
|
+
try:
|
108
|
+
with conn.cursor() as cur:
|
109
|
+
print(f"⬇️ Rolling back {version} -> {os.path.basename(sql_path)}")
|
110
|
+
cur.execute(sql)
|
111
|
+
cur.execute(f"DELETE FROM {MIGRATION_TABLE} WHERE version = %s", (version,))
|
112
|
+
conn.commit()
|
113
|
+
print(f"✅ {version} rolled back.")
|
114
|
+
except Exception as e:
|
115
|
+
conn.rollback()
|
116
|
+
print(f"❌ Failed to rollback {version}: {e}")
|
117
|
+
raise
|
118
|
+
|
119
|
+
|
120
|
+
def find_migration_files(migrations_root):
|
121
|
+
"""Return sorted list of upgrade filenames in migrations/upgrade"""
|
122
|
+
upgrade_dir = os.path.join(migrations_root, UPGRADE_SUBDIR)
|
123
|
+
if not os.path.isdir(upgrade_dir):
|
124
|
+
return []
|
125
|
+
files = [f for f in os.listdir(upgrade_dir) if f.endswith(".sql")]
|
126
|
+
files = sorted(files)
|
127
|
+
return files
|
128
|
+
|
129
|
+
|
130
|
+
def parse_version_from_filename(filename):
|
131
|
+
"""Expect filenames like 001.description.upgrade.sql or 001.description.sql"""
|
132
|
+
base = os.path.basename(filename)
|
133
|
+
parts = base.split(".", 1)
|
134
|
+
return parts[0]
|
135
|
+
|
136
|
+
|
137
|
+
def full_upgrade_path(migrations_root, filename):
|
138
|
+
return os.path.join(migrations_root, UPGRADE_SUBDIR, filename)
|
139
|
+
|
140
|
+
|
141
|
+
def full_downgrade_path(migrations_root, filename):
|
142
|
+
return os.path.join(migrations_root, DOWNGRADE_SUBDIR, filename)
|
143
|
+
|
144
|
+
|
145
|
+
def get_next_version_str(migrations_root):
|
146
|
+
upgrade_dir = os.path.join(migrations_root, UPGRADE_SUBDIR)
|
147
|
+
os.makedirs(upgrade_dir, exist_ok=True)
|
148
|
+
files = [f for f in os.listdir(upgrade_dir) if f[0:3].isdigit()]
|
149
|
+
versions = [int(f[0:3]) for f in files if f[0:3].isdigit()]
|
150
|
+
next_v = max(versions) + 1 if versions else 1
|
151
|
+
return f"{next_v:03d}"
|
152
|
+
|
153
|
+
|
154
|
+
def create_migration(migrations_root, description):
|
155
|
+
desc = description.strip()
|
156
|
+
# sanitize description
|
157
|
+
desc_clean = "_".join(
|
158
|
+
c for c in desc.lower().replace(" ", "_") if (c.isalnum() or c == "_")
|
159
|
+
)
|
160
|
+
version = get_next_version_str(migrations_root)
|
161
|
+
upgrade_name = f"{version}.{desc_clean}.upgrade.sql"
|
162
|
+
downgrade_name = f"{version}.{desc_clean}.downgrade.sql"
|
163
|
+
|
164
|
+
upgrade_path = full_upgrade_path(migrations_root, upgrade_name)
|
165
|
+
downgrade_path = full_downgrade_path(migrations_root, downgrade_name)
|
166
|
+
|
167
|
+
# ensure dirs
|
168
|
+
os.makedirs(os.path.dirname(upgrade_path), exist_ok=True)
|
169
|
+
os.makedirs(os.path.dirname(downgrade_path), exist_ok=True)
|
170
|
+
|
171
|
+
ts = datetime.utcnow().isoformat() + "Z"
|
172
|
+
with open(upgrade_path, "w", encoding="utf-8") as f:
|
173
|
+
f.write(f"-- +upgrade {version} {desc}\n-- created at {ts}\n\n")
|
174
|
+
with open(downgrade_path, "w", encoding="utf-8") as f:
|
175
|
+
f.write(f"-- +downgrade {version} {desc}\n-- created at {ts}\n\n")
|
176
|
+
|
177
|
+
print(f"Created:\n {upgrade_path}\n {downgrade_path}")
|
178
|
+
return upgrade_path, downgrade_path
|
179
|
+
|
180
|
+
|
181
|
+
def cmd_upgrade(args):
|
182
|
+
conninfo = build_conninfo_from_args(args)
|
183
|
+
migrations_root = args.migrations
|
184
|
+
files = find_migration_files(migrations_root)
|
185
|
+
if not files:
|
186
|
+
print("No migration files found.")
|
187
|
+
return
|
188
|
+
|
189
|
+
with get_connection(conninfo) as conn:
|
190
|
+
ensure_migration_table(conn)
|
191
|
+
applied = set(get_applied_migrations(conn))
|
192
|
+
for fname in files:
|
193
|
+
version = parse_version_from_filename(fname)
|
194
|
+
if version in applied:
|
195
|
+
print(f"Skipping {fname} (already applied).")
|
196
|
+
continue
|
197
|
+
sql_path = full_upgrade_path(migrations_root, fname)
|
198
|
+
apply_migration(conn, version, sql_path)
|
199
|
+
|
200
|
+
|
201
|
+
def cmd_history(args):
|
202
|
+
conninfo = build_conninfo_from_args(args)
|
203
|
+
with get_connection(conninfo) as conn:
|
204
|
+
ensure_migration_table(conn)
|
205
|
+
applied = get_applied_migrations(conn)
|
206
|
+
print("Applied migrations (ordered):")
|
207
|
+
for v in applied:
|
208
|
+
print(" -", v)
|
209
|
+
|
210
|
+
|
211
|
+
def cmd_show(args):
|
212
|
+
conninfo = build_conninfo_from_args(args)
|
213
|
+
with get_connection(conninfo) as conn:
|
214
|
+
ensure_migration_table(conn)
|
215
|
+
applied = get_applied_migrations(conn)
|
216
|
+
if not applied:
|
217
|
+
print("Database has no applied migrations (base).")
|
218
|
+
else:
|
219
|
+
print("Current version:", applied[-1])
|
220
|
+
|
221
|
+
|
222
|
+
def cmd_downgrade(args):
|
223
|
+
conninfo = build_conninfo_from_args(args)
|
224
|
+
migrations_root = args.migrations
|
225
|
+
|
226
|
+
with get_connection(conninfo) as conn:
|
227
|
+
ensure_migration_table(conn)
|
228
|
+
applied = get_applied_migrations(conn)
|
229
|
+
|
230
|
+
if not applied:
|
231
|
+
print("No migrations applied.")
|
232
|
+
return
|
233
|
+
|
234
|
+
target = args.target # None => step=1, "base" => all, specific version => to that
|
235
|
+
if target is None:
|
236
|
+
# default: rollback 1 step
|
237
|
+
target_index = len(applied) - 2
|
238
|
+
elif target == "base":
|
239
|
+
target_index = -1
|
240
|
+
else:
|
241
|
+
if target not in applied:
|
242
|
+
print(f"Target {target} not found in applied migrations.")
|
243
|
+
return
|
244
|
+
target_index = applied.index(target)
|
245
|
+
|
246
|
+
to_rollback = applied[target_index + 1 :]
|
247
|
+
if not to_rollback:
|
248
|
+
print("Nothing to rollback.")
|
249
|
+
return
|
250
|
+
|
251
|
+
# rollback in reverse order
|
252
|
+
for version in reversed(to_rollback):
|
253
|
+
# find corresponding file in downgrade dir
|
254
|
+
# find filename that starts with version in upgrade dir to get remainder name
|
255
|
+
upgrade_dir = os.path.join(migrations_root, UPGRADE_SUBDIR)
|
256
|
+
candidates = [f for f in os.listdir(upgrade_dir) if f.startswith(version)]
|
257
|
+
if not candidates:
|
258
|
+
print(f"⚠️ No upgrade file found for version {version} (can't map to downgrade).")
|
259
|
+
continue
|
260
|
+
# infer name: replace 'upgrade' -> 'downgrade'
|
261
|
+
up_fname = candidates[0]
|
262
|
+
down_fname = up_fname.replace(".upgrade.", ".downgrade.")
|
263
|
+
down_path = full_downgrade_path(migrations_root, down_fname)
|
264
|
+
if not os.path.exists(down_path):
|
265
|
+
print(f"⚠️ No rollback file for {version} -> expected {down_path}")
|
266
|
+
continue
|
267
|
+
rollback_migration_from_file(conn, version, down_path)
|
268
|
+
|
269
|
+
|
270
|
+
def build_arg_parser():
|
271
|
+
p = argparse.ArgumentParser(prog="pgmigrate", description="Simple PostgreSQL migration tool")
|
272
|
+
p.add_argument("--env-file", default=".env", help="Path to .env file (default: .env)")
|
273
|
+
p.add_argument(
|
274
|
+
"--migrations",
|
275
|
+
default=DEFAULT_MIGRATIONS_DIR,
|
276
|
+
help=f"Root migrations dir (default: {DEFAULT_MIGRATIONS_DIR})",
|
277
|
+
)
|
278
|
+
|
279
|
+
# DB connection options (mutually override database_url)
|
280
|
+
p.add_argument("--database-url", help="Full DATABASE_URL (postgresql://...)", default=None)
|
281
|
+
p.add_argument("--db-host", help="DB host", default=os.getenv("POSTGRES_HOST"))
|
282
|
+
p.add_argument("--db-port", help="DB port", default=os.getenv("POSTGRES_PORT"))
|
283
|
+
p.add_argument("--db-name", help="DB name", default=os.getenv("POSTGRES_DB"))
|
284
|
+
p.add_argument("--db-user", help="DB user", default=os.getenv("POSTGRES_USER"))
|
285
|
+
p.add_argument("--db-password", help="DB password", default=os.getenv("POSTGRES_PASSWORD"))
|
286
|
+
|
287
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
288
|
+
|
289
|
+
sub.add_parser("upgrade", help="Apply all pending migrations")
|
290
|
+
|
291
|
+
down_p = sub.add_parser("downgrade", help="Rollback migrations to target version (or 1 step if no target)")
|
292
|
+
down_p.add_argument("target", nargs="?", help="target version (e.g. 002) or 'base'")
|
293
|
+
|
294
|
+
sub.add_parser("history", help="Show applied migrations (history)")
|
295
|
+
|
296
|
+
sub.add_parser("show", help="Show current applied version")
|
297
|
+
|
298
|
+
create_p = sub.add_parser("create", help="Create new migration skeleton")
|
299
|
+
create_p.add_argument("description", nargs="+", help="Migration description (quoted)")
|
300
|
+
|
301
|
+
return p
|
302
|
+
|
303
|
+
|
304
|
+
def main(argv=None):
|
305
|
+
argv = argv if argv is not None else sys.argv[1:]
|
306
|
+
parser = build_arg_parser()
|
307
|
+
args = parser.parse_args(argv)
|
308
|
+
|
309
|
+
# Load env file first (do not overwrite existing env vars)
|
310
|
+
if args.env_file:
|
311
|
+
load_env_file(args.env_file)
|
312
|
+
|
313
|
+
# prefer CLI args, else env
|
314
|
+
# Re-populate db fields from env if not provided explicitly
|
315
|
+
# (build_conninfo handles database_url precedence)
|
316
|
+
# Dispatch commands:
|
317
|
+
if args.cmd == "create":
|
318
|
+
desc = " ".join(args.description)
|
319
|
+
create_migration(args.migrations, desc)
|
320
|
+
return
|
321
|
+
|
322
|
+
# For commands that touch DB, ensure connection info exists
|
323
|
+
# Build conninfo now (will use args or environment)
|
324
|
+
# If neither database_url nor db_name provided, abort
|
325
|
+
conninfo = build_conninfo_from_args(args)
|
326
|
+
if not conninfo:
|
327
|
+
print("No database connection info provided. Provide --database-url or host/name/user/password or set env vars.")
|
328
|
+
sys.exit(2)
|
329
|
+
|
330
|
+
if args.cmd == "upgrade":
|
331
|
+
cmd_upgrade(args)
|
332
|
+
elif args.cmd == "downgrade":
|
333
|
+
cmd_downgrade(args)
|
334
|
+
elif args.cmd == "history":
|
335
|
+
cmd_history(args)
|
336
|
+
elif args.cmd == "show":
|
337
|
+
cmd_show(args)
|
338
|
+
else:
|
339
|
+
print("Unknown command:", args.cmd)
|
340
|
+
sys.exit(2)
|
@@ -0,0 +1,40 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: pgmini-migrate
|
3
|
+
Version: 0.1.2
|
4
|
+
Summary: A minimal PostgreSQL migration tool for Python
|
5
|
+
Author-email: Nguyen Vu Duy Luan <nvdluan@gmail.com>
|
6
|
+
License: Copyright (c) 2025 Nguyen Vu Duy Luan
|
7
|
+
Project-URL: Homepage, https://github.com/NVDLuan/pgmini-migrate
|
8
|
+
Project-URL: Issues, https://github.com/NVDLuan/pgmini-migrate/issues
|
9
|
+
Requires-Python: >=3.10
|
10
|
+
Description-Content-Type: text/markdown
|
11
|
+
License-File: LICENSE
|
12
|
+
Requires-Dist: psycopg[binary]>=3.1.8
|
13
|
+
Requires-Dist: dotenv<0.10.0,>=0.9.9
|
14
|
+
Requires-Dist: click<9.0.0,>=8.2.1
|
15
|
+
Dynamic: license-file
|
16
|
+
|
17
|
+
# pgmini-migrate
|
18
|
+
|
19
|
+
`pgmini-migrate` is a **lightweight SQL migration tool** for PostgreSQL.
|
20
|
+
It allows you to easily **create migration files** and run **upgrades/downgrades** using simple CLI commands.
|
21
|
+
|
22
|
+
No ORM required — just pure SQL.
|
23
|
+
|
24
|
+
---
|
25
|
+
|
26
|
+
## ✨ Features
|
27
|
+
- Auto-generate migration files (`upgrade` + `downgrade`).
|
28
|
+
- Run **upgrade** or **downgrade** up to a specific version.
|
29
|
+
- Two connection options:
|
30
|
+
- via `DATABASE_URL`
|
31
|
+
- via `host`, `port`, `user`, `password`, `dbname`.
|
32
|
+
- Configurable migrations directory.
|
33
|
+
- Minimal, simple, and PostgreSQL-focused.
|
34
|
+
|
35
|
+
---
|
36
|
+
|
37
|
+
## 📦 Installation
|
38
|
+
|
39
|
+
```bash
|
40
|
+
pip install pgmini-migrate
|
@@ -3,9 +3,9 @@ README.md
|
|
3
3
|
pyproject.toml
|
4
4
|
setup.py
|
5
5
|
pgmini_migrate/__init__.py
|
6
|
+
pgmini_migrate/__main__.py
|
6
7
|
pgmini_migrate/cli.py
|
7
|
-
pgmini_migrate/
|
8
|
-
pgmini_migrate/runner.py
|
8
|
+
pgmini_migrate/utils.py
|
9
9
|
pgmini_migrate.egg-info/PKG-INFO
|
10
10
|
pgmini_migrate.egg-info/SOURCES.txt
|
11
11
|
pgmini_migrate.egg-info/dependency_links.txt
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "pgmini-migrate"
|
7
|
-
version = "0.1.
|
7
|
+
version = "0.1.2"
|
8
8
|
description = "A minimal PostgreSQL migration tool for Python"
|
9
9
|
readme = "README.md"
|
10
10
|
license = { file = "LICENSE" }
|
@@ -13,9 +13,10 @@ authors = [
|
|
13
13
|
]
|
14
14
|
dependencies = [
|
15
15
|
"psycopg[binary]>=3.1.8",
|
16
|
-
"dotenv (>=0.9.9,<0.10.0)"
|
16
|
+
"dotenv (>=0.9.9,<0.10.0)",
|
17
|
+
"click (>=8.2.1,<9.0.0)"
|
17
18
|
]
|
18
|
-
requires-python = ">=3.
|
19
|
+
requires-python = ">=3.10"
|
19
20
|
|
20
21
|
[project.scripts]
|
21
22
|
pgmigrate = "pgmini_migrate.cli:main"
|
pgmini_migrate-0.1.1/PKG-INFO
DELETED
@@ -1,22 +0,0 @@
|
|
1
|
-
Metadata-Version: 2.4
|
2
|
-
Name: pgmini-migrate
|
3
|
-
Version: 0.1.1
|
4
|
-
Summary: A minimal PostgreSQL migration tool for Python
|
5
|
-
Author-email: Nguyen Vu Duy Luan <nvdluan@gmail.com>
|
6
|
-
License: Copyright (c) 2025 Nguyen Vu Duy Luan
|
7
|
-
Project-URL: Homepage, https://github.com/NVDLuan/pgmini-migrate
|
8
|
-
Project-URL: Issues, https://github.com/NVDLuan/pgmini-migrate/issues
|
9
|
-
Requires-Python: >=3.8
|
10
|
-
Description-Content-Type: text/markdown
|
11
|
-
License-File: LICENSE
|
12
|
-
Requires-Dist: psycopg[binary]>=3.1.8
|
13
|
-
Requires-Dist: dotenv<0.10.0,>=0.9.9
|
14
|
-
Dynamic: license-file
|
15
|
-
|
16
|
-
# pgmini-migrate
|
17
|
-
|
18
|
-
A minimal PostgreSQL migration tool using psycopg3.
|
19
|
-
|
20
|
-
## Install
|
21
|
-
```bash
|
22
|
-
pip install pgmini-migrate
|
pgmini_migrate-0.1.1/README.md
DELETED
@@ -1,30 +0,0 @@
|
|
1
|
-
import argparse
|
2
|
-
from pgmini_migrate.generator import create_migration
|
3
|
-
from pgmini_migrate.runner import upgrade, downgrade
|
4
|
-
|
5
|
-
def main():
|
6
|
-
parser = argparse.ArgumentParser(prog="pgmigrate", description="Mini migration tool for PostgreSQL")
|
7
|
-
sub = parser.add_subparsers(dest="command")
|
8
|
-
|
9
|
-
# create
|
10
|
-
create_cmd = sub.add_parser("create", help="Create new migration")
|
11
|
-
create_cmd.add_argument("description", nargs="+", help="Migration description")
|
12
|
-
|
13
|
-
# upgrade
|
14
|
-
upgrade_cmd = sub.add_parser("upgrade", help="Apply migrations")
|
15
|
-
upgrade_cmd.add_argument("target", nargs="?", help="Target version (default: latest)")
|
16
|
-
|
17
|
-
# downgrade
|
18
|
-
downgrade_cmd = sub.add_parser("downgrade", help="Rollback migrations")
|
19
|
-
downgrade_cmd.add_argument("target", nargs="?", help="Target version")
|
20
|
-
|
21
|
-
args = parser.parse_args()
|
22
|
-
|
23
|
-
if args.command == "create":
|
24
|
-
create_migration(" ".join(args.description))
|
25
|
-
elif args.command == "upgrade":
|
26
|
-
upgrade(args.target)
|
27
|
-
elif args.command == "downgrade":
|
28
|
-
downgrade(args.target)
|
29
|
-
else:
|
30
|
-
parser.print_help()
|
@@ -1,37 +0,0 @@
|
|
1
|
-
import os
|
2
|
-
|
3
|
-
|
4
|
-
MIGRATIONS_DIR = os.getenv("MIGRATIONS_DIR", "migrations")
|
5
|
-
UPGRADE_DIR = os.path.join(MIGRATIONS_DIR, "upgrade")
|
6
|
-
DOWNGRADE_DIR = os.path.join(MIGRATIONS_DIR, "downgrade")
|
7
|
-
|
8
|
-
|
9
|
-
def ensure_dirs():
|
10
|
-
os.makedirs(UPGRADE_DIR, exist_ok=True)
|
11
|
-
os.makedirs(DOWNGRADE_DIR, exist_ok=True)
|
12
|
-
|
13
|
-
def get_next_version():
|
14
|
-
ensure_dirs()
|
15
|
-
files = os.listdir(UPGRADE_DIR)
|
16
|
-
versions = []
|
17
|
-
for f in files:
|
18
|
-
if f[0:3].isdigit():
|
19
|
-
versions.append(int(f[0:3]))
|
20
|
-
return max(versions) + 1 if versions else 1
|
21
|
-
|
22
|
-
|
23
|
-
def create_migration(description: str):
|
24
|
-
version = get_next_version()
|
25
|
-
version_str = f"{version:03d}"
|
26
|
-
# format description thành tên file đẹp
|
27
|
-
desc_clean = description.lower().replace(" ", "_")
|
28
|
-
|
29
|
-
upgrade_file = os.path.join(UPGRADE_DIR, f"{version_str}.{desc_clean}.upgrade.sql")
|
30
|
-
downgrade_file = os.path.join(DOWNGRADE_DIR, f"{version_str}.{desc_clean}.downgrade.sql")
|
31
|
-
|
32
|
-
with open(upgrade_file, "w") as f:
|
33
|
-
f.write(f"-- Migration {version_str}: {description}\n")
|
34
|
-
with open(downgrade_file, "w") as f:
|
35
|
-
f.write(f"-- Rollback {version_str}: {description}\n")
|
36
|
-
|
37
|
-
print(f"✅ Created migration files:\n {upgrade_file}\n {downgrade_file}")
|
@@ -1,156 +0,0 @@
|
|
1
|
-
import os
|
2
|
-
from contextlib import contextmanager
|
3
|
-
|
4
|
-
import psycopg
|
5
|
-
from dotenv import load_dotenv
|
6
|
-
|
7
|
-
load_dotenv('.env')
|
8
|
-
|
9
|
-
DB_CONFIG = {
|
10
|
-
"dbname": os.getenv("POSTGRES_DB", "postgres"),
|
11
|
-
"user": os.getenv("POSTGRES_USER", "postgres"),
|
12
|
-
"password": os.getenv("POSTGRES_PASSWORD", "postgres"),
|
13
|
-
"host": os.getenv("POSTGRES_HOST", "localhost"),
|
14
|
-
"port": os.getenv("POSTGRES_PORT", "5432"),
|
15
|
-
}
|
16
|
-
|
17
|
-
MIGRATIONS_DIR = os.getenv("MIGRATIONS_DIR", "migrations")
|
18
|
-
UPGRADE_DIR = os.path.join(MIGRATIONS_DIR, "upgrade")
|
19
|
-
DOWNGRADE_DIR = os.path.join(MIGRATIONS_DIR, "downgrade")
|
20
|
-
|
21
|
-
|
22
|
-
@contextmanager
|
23
|
-
def get_connection():
|
24
|
-
conn = psycopg.connect(**DB_CONFIG)
|
25
|
-
try:
|
26
|
-
yield conn
|
27
|
-
finally:
|
28
|
-
conn.close()
|
29
|
-
|
30
|
-
|
31
|
-
def ensure_migration_table(conn):
|
32
|
-
with conn.cursor() as cur:
|
33
|
-
cur.execute("""
|
34
|
-
CREATE TABLE IF NOT EXISTS schema_migrations
|
35
|
-
(
|
36
|
-
version
|
37
|
-
VARCHAR
|
38
|
-
(
|
39
|
-
255
|
40
|
-
) PRIMARY KEY,
|
41
|
-
applied_at TIMESTAMP DEFAULT NOW
|
42
|
-
(
|
43
|
-
)
|
44
|
-
)
|
45
|
-
""")
|
46
|
-
conn.commit()
|
47
|
-
|
48
|
-
|
49
|
-
def get_applied_migrations(conn):
|
50
|
-
with conn.cursor() as cur:
|
51
|
-
cur.execute("SELECT version FROM schema_migrations ORDER BY version")
|
52
|
-
rows = cur.fetchall()
|
53
|
-
return [row[0] for row in rows]
|
54
|
-
|
55
|
-
|
56
|
-
def apply_migration(conn, version, sql_path):
|
57
|
-
with open(sql_path, "r") as f:
|
58
|
-
sql = f.read()
|
59
|
-
|
60
|
-
try:
|
61
|
-
with conn.cursor() as cur:
|
62
|
-
print(f"⬆️ Applying migration {version} ...")
|
63
|
-
cur.execute(sql)
|
64
|
-
cur.execute(
|
65
|
-
"INSERT INTO schema_migrations (version) VALUES (%s)", (version,)
|
66
|
-
)
|
67
|
-
conn.commit()
|
68
|
-
print(f"✅ Migration {version} applied.")
|
69
|
-
except Exception as e:
|
70
|
-
conn.rollback()
|
71
|
-
print(f"❌ Migration {version} failed: {e}")
|
72
|
-
raise
|
73
|
-
|
74
|
-
|
75
|
-
def rollback_migration(conn, version, sql_file):
|
76
|
-
down_file = f"{version}.{sql_file.split('.', 1)[1].replace('upgrade', 'downgrade')}"
|
77
|
-
sql_path = os.path.join(DOWNGRADE_DIR, down_file)
|
78
|
-
|
79
|
-
if not os.path.exists(sql_path):
|
80
|
-
print(f"⚠️ No rollback file for {version}")
|
81
|
-
return
|
82
|
-
|
83
|
-
with open(sql_path, "r") as f:
|
84
|
-
sql = f.read()
|
85
|
-
|
86
|
-
try:
|
87
|
-
with conn.cursor() as cur:
|
88
|
-
print(f"⬇️ Rolling back migration {version} ...")
|
89
|
-
cur.execute(sql)
|
90
|
-
cur.execute("DELETE FROM schema_migrations WHERE version = %s", (version,))
|
91
|
-
conn.commit()
|
92
|
-
print(f"✅ Migration {version} rolled back.")
|
93
|
-
except Exception as e:
|
94
|
-
conn.rollback()
|
95
|
-
print(f"❌ Rollback {version} failed: {e}")
|
96
|
-
raise
|
97
|
-
|
98
|
-
|
99
|
-
def upgrade():
|
100
|
-
with get_connection() as conn:
|
101
|
-
ensure_migration_table(conn)
|
102
|
-
applied = set(get_applied_migrations(conn))
|
103
|
-
|
104
|
-
migration_files = sorted(os.listdir(UPGRADE_DIR))
|
105
|
-
for filename in migration_files:
|
106
|
-
version = filename.split(".")[0] # 001_init.sql -> 001
|
107
|
-
if version in applied or not filename.endswith(".sql"):
|
108
|
-
continue
|
109
|
-
sql_path = os.path.join(UPGRADE_DIR, filename)
|
110
|
-
apply_migration(conn, version, sql_path)
|
111
|
-
|
112
|
-
|
113
|
-
def downgrade(target_version=None):
|
114
|
-
with get_connection() as conn:
|
115
|
-
ensure_migration_table(conn)
|
116
|
-
applied = get_applied_migrations(conn)
|
117
|
-
|
118
|
-
if not applied:
|
119
|
-
print("⚠️ No migrations to rollback.")
|
120
|
-
return
|
121
|
-
|
122
|
-
if target_version == "base":
|
123
|
-
target_index = -1
|
124
|
-
elif target_version:
|
125
|
-
if target_version not in applied:
|
126
|
-
print(f"⚠️ Target version {target_version} not found in applied migrations.")
|
127
|
-
return
|
128
|
-
target_index = applied.index(target_version)
|
129
|
-
else:
|
130
|
-
# Nếu không truyền version → rollback 1 bước
|
131
|
-
target_index = len(applied) - 2
|
132
|
-
|
133
|
-
to_rollback = applied[target_index + 1:] # những migration mới hơn target
|
134
|
-
if not to_rollback:
|
135
|
-
print("⚠️ Nothing to rollback.")
|
136
|
-
return
|
137
|
-
|
138
|
-
# rollback ngược thứ tự
|
139
|
-
for version in reversed(to_rollback):
|
140
|
-
sql_file = next(
|
141
|
-
(f for f in os.listdir(UPGRADE_DIR) if f.startswith(version)), None
|
142
|
-
)
|
143
|
-
if not sql_file:
|
144
|
-
print(f"⚠️ No migration file found for {version}")
|
145
|
-
continue
|
146
|
-
sql_path = os.path.join(UPGRADE_DIR, sql_file)
|
147
|
-
rollback_migration(conn, version, sql_path)
|
148
|
-
|
149
|
-
|
150
|
-
def history():
|
151
|
-
with get_connection() as conn:
|
152
|
-
ensure_migration_table(conn)
|
153
|
-
applied = get_applied_migrations(conn)
|
154
|
-
print("📜 Applied migrations:")
|
155
|
-
for v in applied:
|
156
|
-
print(f" - {v}")
|
@@ -1,22 +0,0 @@
|
|
1
|
-
Metadata-Version: 2.4
|
2
|
-
Name: pgmini-migrate
|
3
|
-
Version: 0.1.1
|
4
|
-
Summary: A minimal PostgreSQL migration tool for Python
|
5
|
-
Author-email: Nguyen Vu Duy Luan <nvdluan@gmail.com>
|
6
|
-
License: Copyright (c) 2025 Nguyen Vu Duy Luan
|
7
|
-
Project-URL: Homepage, https://github.com/NVDLuan/pgmini-migrate
|
8
|
-
Project-URL: Issues, https://github.com/NVDLuan/pgmini-migrate/issues
|
9
|
-
Requires-Python: >=3.8
|
10
|
-
Description-Content-Type: text/markdown
|
11
|
-
License-File: LICENSE
|
12
|
-
Requires-Dist: psycopg[binary]>=3.1.8
|
13
|
-
Requires-Dist: dotenv<0.10.0,>=0.9.9
|
14
|
-
Dynamic: license-file
|
15
|
-
|
16
|
-
# pgmini-migrate
|
17
|
-
|
18
|
-
A minimal PostgreSQL migration tool using psycopg3.
|
19
|
-
|
20
|
-
## Install
|
21
|
-
```bash
|
22
|
-
pip install pgmini-migrate
|
File without changes
|
/pgmini_migrate-0.1.1/pgmini_migrate/__init__.py → /pgmini_migrate-0.1.2/pgmini_migrate/utils.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|