pgmini-migrate 0.1.0__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.
@@ -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,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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)
File without changes
@@ -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
@@ -2,6 +2,10 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  setup.py
5
+ pgmini_migrate/__init__.py
6
+ pgmini_migrate/__main__.py
7
+ pgmini_migrate/cli.py
8
+ pgmini_migrate/utils.py
5
9
  pgmini_migrate.egg-info/PKG-INFO
6
10
  pgmini_migrate.egg-info/SOURCES.txt
7
11
  pgmini_migrate.egg-info/dependency_links.txt
@@ -1,2 +1,3 @@
1
1
  psycopg[binary]>=3.1.8
2
2
  dotenv<0.10.0,>=0.9.9
3
+ click<9.0.0,>=8.2.1
@@ -0,0 +1 @@
1
+ pgmini_migrate
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pgmini-migrate"
7
- version = "0.1.0"
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.8"
19
+ requires-python = ">=3.10"
19
20
 
20
21
  [project.scripts]
21
22
  pgmigrate = "pgmini_migrate.cli:main"
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="pgmini-migrate",
5
- version="0.1.0",
5
+ version="0.1.2",
6
6
  packages=find_packages(),
7
7
  install_requires=["psycopg2-binary", 'psycopg', 'python-dotenv'],
8
8
  entry_points={
@@ -1,22 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pgmini-migrate
3
- Version: 0.1.0
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
@@ -1,7 +0,0 @@
1
- # pgmini-migrate
2
-
3
- A minimal PostgreSQL migration tool using psycopg3.
4
-
5
- ## Install
6
- ```bash
7
- pip install pgmini-migrate
@@ -1,22 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pgmini-migrate
3
- Version: 0.1.0
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
File without changes