jetbase 0.7.0__py3-none-any.whl

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.
jetbase/cli/main.py ADDED
@@ -0,0 +1,61 @@
1
+ import typer
2
+
3
+ from jetbase.core.initialize import initialize_cmd
4
+ from jetbase.core.rollback import rollback_cmd
5
+ from jetbase.core.upgrade import upgrade_cmd
6
+
7
+ app = typer.Typer(help="Jetbase CLI")
8
+
9
+
10
+ @app.command()
11
+ def init():
12
+ """Initialize jetbase in current directory"""
13
+ initialize_cmd()
14
+
15
+
16
+ @app.command()
17
+ def upgrade(
18
+ count: int = typer.Option(
19
+ None, "--count", "-c", help="Number of migrations to apply"
20
+ ),
21
+ to_version: str | None = typer.Option(
22
+ None, "--to-version", "-t", help="Rollback to a specific version"
23
+ ),
24
+ dry_run: bool = typer.Option(
25
+ False, "--dry-run", "-d", help="Simulate the upgrade without making changes"
26
+ ),
27
+ ):
28
+ """Execute pending migrations"""
29
+ upgrade_cmd(
30
+ count=count,
31
+ to_version=to_version.replace("_", ".") if to_version else None,
32
+ dry_run=dry_run,
33
+ )
34
+
35
+
36
+ @app.command()
37
+ def rollback(
38
+ count: int = typer.Option(
39
+ None, "--count", "-c", help="Number of migrations to rollback"
40
+ ),
41
+ to_version: str | None = typer.Option(
42
+ None, "--to-version", "-t", help="Rollback to a specific version"
43
+ ),
44
+ dry_run: bool = typer.Option(
45
+ False, "--dry-run", "-d", help="Simulate the upgrade without making changes"
46
+ ),
47
+ ):
48
+ """Rollback migration(s)"""
49
+ rollback_cmd(
50
+ count=count,
51
+ to_version=to_version.replace("_", ".") if to_version else None,
52
+ dry_run=dry_run,
53
+ )
54
+
55
+
56
+ def main() -> None:
57
+ app()
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
jetbase/config.py ADDED
@@ -0,0 +1,60 @@
1
+ import importlib.machinery
2
+ import importlib.util
3
+ import os
4
+
5
+ # from importlib import ModuleType
6
+ from types import ModuleType
7
+ from typing import Any
8
+
9
+ from jetbase.constants import CONFIG_FILE
10
+
11
+
12
+ def get_sqlalchemy_url(filename: str = CONFIG_FILE) -> str:
13
+ """
14
+ Load a config file and extract the sqlalchemy_url.
15
+
16
+ Args:
17
+ config_filename: Name of the config file (e.g., "config.py")
18
+
19
+ Returns:
20
+ The sqlalchemy_url from the config file, or None if not found
21
+ """
22
+ config_path: str = os.path.join(os.getcwd(), filename)
23
+ spec: importlib.machinery.ModuleSpec | None = (
24
+ importlib.util.spec_from_file_location("config", config_path)
25
+ )
26
+
27
+ assert spec is not None
28
+ assert spec.loader is not None
29
+
30
+ config: ModuleType = importlib.util.module_from_spec(spec)
31
+ spec.loader.exec_module(module=config)
32
+
33
+ raw_sqlalchemy_url: Any | None = getattr(config, "sqlalchemy_url", None)
34
+
35
+ if raw_sqlalchemy_url is None:
36
+ raise AttributeError(
37
+ f"'sqlalchemy_url' not found or is set to None. Please define it in {config_path}."
38
+ )
39
+
40
+ sqlalchemy_url: str = _validate_sqlalchemy_url(url=raw_sqlalchemy_url)
41
+
42
+ return sqlalchemy_url
43
+
44
+
45
+ def _validate_sqlalchemy_url(url: Any) -> str:
46
+ """
47
+ Validates a SQLAlchemy URL string.
48
+ This function checks if the provided URL is a valid string.
49
+ Args:
50
+ url (Any): The SQLAlchemy URL to validate (could be any type from user config).
51
+ Returns:
52
+ str: The validated SQLAlchemy URL string.
53
+ Raises:
54
+ TypeError: If the provided URL is not a string.
55
+ """
56
+
57
+ if not isinstance(url, str):
58
+ raise TypeError(f"sqlalchemy_url must be a string, got {type(url).__name__}")
59
+
60
+ return url
jetbase/constants.py ADDED
@@ -0,0 +1,12 @@
1
+ from typing import Final
2
+
3
+ BASE_DIR: Final[str] = "jetbase"
4
+ MIGRATIONS_DIR: Final[str] = "migrations"
5
+ CONFIG_FILE: Final[str] = "config.py"
6
+
7
+ CONFIG_FILE_CONTENT: Final[str] = """
8
+ # Jetbase Configuration
9
+ # Update the sqlalchemy_url with your database connection string.
10
+
11
+ sqlalchemy_url = "postgresql://user:password@localhost:5432/mydb"
12
+ """
@@ -0,0 +1,38 @@
1
+ import os
2
+
3
+ from jetbase.core.file_parser import parse_rollback_statements, parse_upgrade_statements
4
+ from jetbase.enums import MigrationOperationType
5
+
6
+
7
+ def process_dry_run(
8
+ version_to_filepath: dict[str, str], migration_operation: MigrationOperationType
9
+ ) -> None:
10
+ print("\nJETBASE - Dry Run Mode")
11
+ print("No SQL will be executed. This is a preview of what would happen.")
12
+ print("----------------------------------------\n\n")
13
+
14
+ for version, file_path in version_to_filepath.items():
15
+ if migration_operation == MigrationOperationType.UPGRADE:
16
+ sql_statements: list[str] = parse_upgrade_statements(
17
+ file_path=file_path, dry_run=True
18
+ )
19
+ elif migration_operation == MigrationOperationType.ROLLBACK:
20
+ sql_statements: list[str] = parse_rollback_statements(
21
+ file_path=file_path, dry_run=True
22
+ )
23
+ else:
24
+ raise NotImplementedError(
25
+ f"Dry run not implemented for migration operation: {migration_operation}"
26
+ )
27
+
28
+ filename: str = os.path.basename(file_path)
29
+
30
+ num_sql_statements: int = len(sql_statements)
31
+
32
+ print(
33
+ f"\nSQL Preview for {filename} ({num_sql_statements} {'statements' if num_sql_statements != 1 else 'statement'})"
34
+ )
35
+ for statement in sql_statements:
36
+ print("\n")
37
+ print(statement)
38
+ print("----------------------------------------\n")
@@ -0,0 +1,199 @@
1
+ import re
2
+
3
+ from jetbase.enums import MigrationOperationType
4
+
5
+
6
+ def parse_upgrade_statements(file_path: str, dry_run: bool = False) -> list[str]:
7
+ statements = []
8
+ current_statement = []
9
+
10
+ with open(file_path, "r") as file:
11
+ for line in file:
12
+ if not dry_run:
13
+ line = line.strip()
14
+ else:
15
+ line = line.rstrip()
16
+
17
+ if (
18
+ line.strip().startswith("--")
19
+ and line[2:].strip().lower() == MigrationOperationType.ROLLBACK.value
20
+ ):
21
+ break
22
+
23
+ if not line or line.strip().startswith("--"):
24
+ continue
25
+ current_statement.append(line)
26
+
27
+ if line.strip().endswith(";"):
28
+ if not dry_run:
29
+ statement = " ".join(current_statement)
30
+ else:
31
+ statement = "\n".join(current_statement)
32
+ statement = statement.rstrip(";").strip()
33
+ if statement:
34
+ statements.append(statement)
35
+ current_statement = []
36
+
37
+ return statements
38
+
39
+
40
+ def parse_rollback_statements(file_path: str, dry_run: bool = False) -> list[str]:
41
+ statements = []
42
+ current_statement = []
43
+ in_rollback_section = False
44
+
45
+ with open(file_path, "r") as file:
46
+ for line in file:
47
+ if not dry_run:
48
+ line = line.strip()
49
+ else:
50
+ line = line.rstrip()
51
+
52
+ if not in_rollback_section:
53
+ if (
54
+ line.strip().startswith("--")
55
+ and line[2:].strip().lower()
56
+ == MigrationOperationType.ROLLBACK.value
57
+ ):
58
+ in_rollback_section = True
59
+ else:
60
+ continue
61
+
62
+ if in_rollback_section:
63
+ if not line or line.strip().startswith("--"):
64
+ continue
65
+ current_statement.append(line)
66
+
67
+ if line.strip().endswith(";"):
68
+ if not dry_run:
69
+ statement = " ".join(current_statement)
70
+ else:
71
+ statement = "\n".join(current_statement)
72
+ statement = statement.rstrip(";").strip()
73
+ if statement:
74
+ statements.append(statement)
75
+ current_statement = []
76
+
77
+ return statements
78
+
79
+
80
+ def is_filename_format_valid(filename: str) -> bool:
81
+ """
82
+ Validates if a filename follows the expected migration file naming convention.
83
+ A valid filename must:
84
+ - Start with "V"
85
+ - Have a valid version number following "V"
86
+ - Contain "__" (double underscore)
87
+ - End with ".sql"
88
+ - Have a valid version number extractable from the filename
89
+ Args:
90
+ filename (str): The filename to validate.
91
+ Returns:
92
+ bool: True if the filename meets all validation criteria, False otherwise.
93
+ Example:
94
+ >>> is_valid_filename_format("V1__initial_migration.sql")
95
+ True
96
+ >>> is_valid_filename_format("migration.sql")
97
+ False
98
+ """
99
+ if not filename.endswith(".sql"):
100
+ return False
101
+ if not filename.startswith("V"):
102
+ return False
103
+ if "__" not in filename:
104
+ return False
105
+ description: str = _get_raw_description_from_filename(filename=filename)
106
+ if len(description.strip()) == 0:
107
+ return False
108
+ raw_version: str = _get_version_from_filename(filename=filename)
109
+ if not _is_valid_version(version=raw_version):
110
+ return False
111
+ return True
112
+
113
+
114
+ def get_description_from_filename(filename: str) -> str:
115
+ """
116
+ Extract and format the description from a migration filename.
117
+
118
+ Args:
119
+ filename: The migration filename (e.g., "V1_2_0__add_feature.sql")
120
+
121
+ Returns:
122
+ str: The formatted description string (e.g., "add feature")
123
+ """
124
+
125
+ raw_description: str = _get_raw_description_from_filename(filename=filename)
126
+ formatted_description: str = raw_description.replace("_", " ")
127
+ return formatted_description
128
+
129
+
130
+ def is_filename_length_valid(filename: str, max_length: int = 512) -> bool:
131
+ """
132
+ Check if the filename length is within the specified maximum length.
133
+
134
+ Args:
135
+ filename: The migration filename to check.
136
+ max_length: The maximum allowed length for the filename.
137
+
138
+ Returns:
139
+ bool: True if the filename length is within the maximum length, False otherwise.
140
+ """
141
+ return len(filename) <= max_length
142
+
143
+
144
+ def _get_version_from_filename(filename: str) -> str:
145
+ """
146
+ Extract the version string from a migration filename.
147
+
148
+ Args:
149
+ filename: The migration filename (e.g., "V1_2_0__add_feature.sql")
150
+
151
+ Returns:
152
+ str: The extracted version string (e.g., "1_2_0")
153
+ """
154
+
155
+ version: str = filename[1 : filename.index("__")]
156
+ return version
157
+
158
+
159
+ def _get_raw_description_from_filename(filename: str) -> str:
160
+ """
161
+ Extract the description string from a migration filename.
162
+
163
+ Args:
164
+ filename: The migration filename (e.g., "V1_2_0__add_feature.sql")
165
+
166
+ Returns:
167
+ str: The extracted description string (e.g., "add_feature")
168
+ """
169
+
170
+ description: str = filename[
171
+ filename.index("__") + 2 : filename.index(".sql")
172
+ ].strip()
173
+ return description
174
+
175
+
176
+ def _is_valid_version(version: str) -> bool:
177
+ """
178
+ Validate that a version string follows the correct format.
179
+
180
+ Rules:
181
+ - Must start and end with a number
182
+ - Can be any length (1 or greater)
183
+ - Can contain periods (.) or underscores (_) between numbers
184
+
185
+ Valid examples: "1", "1.2", "1_2", "1.2.3", "1_2_3", "10.20.30"
186
+ Invalid examples: ".1", "1.", "_1", "1_", "1..2", "1__2", "abc", ""
187
+
188
+ Args:
189
+ version: The version string to validate
190
+
191
+ Returns:
192
+ bool: True if version is valid, False otherwise
193
+ """
194
+ if not version:
195
+ return False
196
+
197
+ # Pattern: starts with digit, ends with digit, can have periods/underscores between digits
198
+ pattern = r"^\d+([._]\d+)*$"
199
+ return bool(re.match(pattern, version))
@@ -0,0 +1,33 @@
1
+ from pathlib import Path
2
+
3
+ from jetbase.constants import BASE_DIR, CONFIG_FILE, CONFIG_FILE_CONTENT, MIGRATIONS_DIR
4
+
5
+
6
+ def create_directory_structure(base_path: str) -> None:
7
+ """
8
+ Create the basic directory structure for a new Jetbase project.
9
+
10
+ This function creates:
11
+ - A migrations directory
12
+ - A config file with default content
13
+
14
+ After creating the structure, it prints a confirmation message.
15
+
16
+ Args:
17
+ base_path (str): The base path where the Jetbase project structure will be created
18
+
19
+ Returns:
20
+ None
21
+ """
22
+ migrations_dir: Path = Path(base_path) / MIGRATIONS_DIR
23
+ migrations_dir.mkdir(parents=True, exist_ok=True)
24
+
25
+ config_path: Path = Path(base_path) / CONFIG_FILE
26
+ with open(config_path, "w") as f:
27
+ f.write(CONFIG_FILE_CONTENT)
28
+
29
+ print(f"Initialized Jetbase project in {Path(base_path).absolute()}")
30
+
31
+
32
+ def initialize_cmd() -> None:
33
+ create_directory_structure(base_path=BASE_DIR)
@@ -0,0 +1,169 @@
1
+ from sqlalchemy import Engine, Result, create_engine, text
2
+
3
+ from jetbase.config import get_sqlalchemy_url
4
+ from jetbase.core.file_parser import get_description_from_filename
5
+ from jetbase.enums import MigrationOperationType
6
+ from jetbase.queries import (
7
+ CHECK_IF_MIGRATIONS_TABLE_EXISTS_QUERY,
8
+ CHECK_IF_VERSION_EXISTS_QUERY,
9
+ CREATE_MIGRATIONS_TABLE_STMT,
10
+ DELETE_VERSION_STMT,
11
+ INSERT_VERSION_STMT,
12
+ LATEST_VERSION_QUERY,
13
+ LATEST_VERSIONS_BY_STARTING_VERSION_QUERY,
14
+ LATEST_VERSIONS_QUERY,
15
+ )
16
+
17
+
18
+ def get_last_updated_version() -> str | None:
19
+ """
20
+ Retrieves the latest version from the database.
21
+ This function connects to the database, executes a query to get the most recent version,
22
+ and returns that version as a string.
23
+ Returns:
24
+ str | None: The latest version string if available, None if no version was found.
25
+ """
26
+
27
+ engine: Engine = create_engine(url=get_sqlalchemy_url())
28
+
29
+ with engine.begin() as connection:
30
+ result: Result[tuple[str]] = connection.execute(LATEST_VERSION_QUERY)
31
+ latest_version: str | None = result.scalar()
32
+ if not latest_version:
33
+ return None
34
+ return latest_version
35
+
36
+
37
+ def create_migrations_table_if_not_exists() -> None:
38
+ """
39
+ Creates the migrations table in the database
40
+ if it does not already exist.
41
+ Returns:
42
+ None
43
+ """
44
+
45
+ engine: Engine = create_engine(url=get_sqlalchemy_url())
46
+ with engine.begin() as connection:
47
+ connection.execute(statement=CREATE_MIGRATIONS_TABLE_STMT)
48
+
49
+
50
+ def run_migration(
51
+ sql_statements: list[str],
52
+ version: str,
53
+ migration_operation: MigrationOperationType,
54
+ filename: str | None = None,
55
+ ) -> None:
56
+ """
57
+ Execute a database migration by running SQL statements and recording the migration version.
58
+ Args:
59
+ sql_statements (list[str]): List of SQL statements to execute as part of the migration
60
+ version (str): Version identifier to record after successful migration
61
+ Returns:
62
+ None
63
+ """
64
+
65
+ if migration_operation == MigrationOperationType.UPGRADE and filename is None:
66
+ raise ValueError("Filename must be provided for upgrade migrations.")
67
+
68
+ engine: Engine = create_engine(url=get_sqlalchemy_url())
69
+
70
+ with engine.begin() as connection:
71
+ for statement in sql_statements:
72
+ connection.execute(text(statement))
73
+
74
+ if migration_operation == MigrationOperationType.UPGRADE:
75
+ assert filename is not None
76
+ description: str = get_description_from_filename(filename=filename)
77
+ connection.execute(
78
+ statement=INSERT_VERSION_STMT,
79
+ parameters={
80
+ "version": version,
81
+ "description": description,
82
+ "filename": filename,
83
+ },
84
+ )
85
+
86
+ elif migration_operation == MigrationOperationType.ROLLBACK:
87
+ connection.execute(
88
+ statement=DELETE_VERSION_STMT, parameters={"version": version}
89
+ )
90
+
91
+
92
+ def get_latest_versions(limit: int) -> list[str]:
93
+ """
94
+ Retrieve the latest N migration versions from the database.
95
+ Args:
96
+ limit (int): The number of latest versions to retrieve
97
+ Returns:
98
+ list[str]: A list of the latest migration version strings
99
+ """
100
+
101
+ engine: Engine = create_engine(url=get_sqlalchemy_url())
102
+ latest_versions: list[str] = []
103
+
104
+ with engine.begin() as connection:
105
+ result: Result[tuple[str]] = connection.execute(
106
+ statement=LATEST_VERSIONS_QUERY,
107
+ parameters={"limit": limit},
108
+ )
109
+ latest_versions: list[str] = [row[0] for row in result.fetchall()]
110
+
111
+ return latest_versions
112
+
113
+
114
+ def get_latest_versions_by_starting_version(
115
+ starting_version: str,
116
+ ) -> list[str]:
117
+ """
118
+ Retrieve the latest N migration versions from the database,
119
+ starting from a specific version.
120
+ Args:
121
+ starting_version (str): The version to start retrieving from
122
+ limit (int): The number of latest versions to retrieve
123
+ Returns:
124
+ list[str]: A list of the latest migration version strings
125
+ """
126
+
127
+ engine: Engine = create_engine(url=get_sqlalchemy_url())
128
+ latest_versions: list[str] = []
129
+ starting_version = starting_version
130
+
131
+ with engine.begin() as connection:
132
+ version_exists_result: Result[tuple[int]] = connection.execute(
133
+ statement=CHECK_IF_VERSION_EXISTS_QUERY,
134
+ parameters={"version": starting_version},
135
+ )
136
+ version_exists: int = version_exists_result.scalar_one()
137
+
138
+ if version_exists == 0:
139
+ raise ValueError(
140
+ f"'{starting_version}' has not been applied yet or does not exist."
141
+ )
142
+
143
+ latest_versions_result: Result[tuple[str]] = connection.execute(
144
+ statement=LATEST_VERSIONS_BY_STARTING_VERSION_QUERY,
145
+ parameters={"starting_version": starting_version},
146
+ )
147
+ latest_versions: list[str] = [
148
+ row[0] for row in latest_versions_result.fetchall()
149
+ ]
150
+
151
+ return latest_versions
152
+
153
+
154
+ def migrations_table_exists() -> bool:
155
+ """
156
+ Check if the jetbase_migrations table exists in the database.
157
+ Returns:
158
+ bool: True if the jetbase_migrations table exists, False otherwise.
159
+ """
160
+
161
+ engine: Engine = create_engine(url=get_sqlalchemy_url())
162
+
163
+ with engine.begin() as connection:
164
+ result: Result[tuple[bool]] = connection.execute(
165
+ statement=CHECK_IF_MIGRATIONS_TABLE_EXISTS_QUERY
166
+ )
167
+ table_exists: bool = result.scalar_one()
168
+
169
+ return table_exists
@@ -0,0 +1,67 @@
1
+ import os
2
+
3
+ from jetbase.core.dry_run import process_dry_run
4
+ from jetbase.core.file_parser import parse_rollback_statements
5
+ from jetbase.core.repository import (
6
+ get_latest_versions,
7
+ get_latest_versions_by_starting_version,
8
+ migrations_table_exists,
9
+ run_migration,
10
+ )
11
+ from jetbase.core.version import get_migration_filepaths_by_version
12
+ from jetbase.enums import MigrationOperationType
13
+
14
+
15
+ def rollback_cmd(
16
+ count: int | None = None, to_version: str | None = None, dry_run: bool = False
17
+ ) -> None:
18
+ table_exists: bool = migrations_table_exists()
19
+ if not table_exists:
20
+ print("No migrations have been applied; nothing to rollback.")
21
+ return
22
+
23
+ if count is not None and to_version is not None:
24
+ raise ValueError(
25
+ "Cannot specify both 'count' and 'to_version' for rollback. "
26
+ "Select only one, or do not specify either to rollback the last migration."
27
+ )
28
+ if count is None and to_version is None:
29
+ count = 1
30
+
31
+ latest_migration_versions: list[str] = []
32
+ if count:
33
+ latest_migration_versions = get_latest_versions(limit=count)
34
+ elif to_version:
35
+ latest_migration_versions = get_latest_versions_by_starting_version(
36
+ starting_version=to_version
37
+ )
38
+
39
+ if not latest_migration_versions:
40
+ print("No migrations have been applied; nothing to rollback.")
41
+ return
42
+
43
+ versions_to_rollback: dict[str, str] = get_migration_filepaths_by_version(
44
+ directory=os.path.join(os.getcwd(), "migrations"),
45
+ version_to_start_from=latest_migration_versions[-1],
46
+ end_version=latest_migration_versions[0],
47
+ )
48
+
49
+ versions_to_rollback: dict[str, str] = dict(reversed(versions_to_rollback.items()))
50
+
51
+ if not dry_run:
52
+ for version, file_path in versions_to_rollback.items():
53
+ sql_statements: list[str] = parse_rollback_statements(file_path=file_path)
54
+ run_migration(
55
+ sql_statements=sql_statements,
56
+ version=version,
57
+ migration_operation=MigrationOperationType.ROLLBACK,
58
+ )
59
+ filename: str = os.path.basename(file_path)
60
+
61
+ print(f"Rollback applied successfully: {filename}")
62
+
63
+ else:
64
+ process_dry_run(
65
+ version_to_filepath=versions_to_rollback,
66
+ migration_operation=MigrationOperationType.ROLLBACK,
67
+ )
@@ -0,0 +1,75 @@
1
+ import os
2
+
3
+ from jetbase.core.dry_run import process_dry_run
4
+ from jetbase.core.file_parser import parse_upgrade_statements
5
+ from jetbase.core.repository import (
6
+ create_migrations_table_if_not_exists,
7
+ get_last_updated_version,
8
+ run_migration,
9
+ )
10
+ from jetbase.core.version import get_migration_filepaths_by_version
11
+ from jetbase.enums import MigrationOperationType
12
+
13
+
14
+ def upgrade_cmd(
15
+ count: int | None = None, to_version: str | None = None, dry_run: bool = False
16
+ ) -> None:
17
+ """
18
+ Run database migrations by applying all pending SQL migration files.
19
+ Executes migration files in order starting from the last applied version,
20
+ updating the migrations tracking table after each successful migration.
21
+
22
+ Returns:
23
+ None
24
+ """
25
+
26
+ if count is not None and to_version is not None:
27
+ raise ValueError(
28
+ "Cannot specify both 'count' and 'to_version' for upgrade. "
29
+ "Select only one, or do not specify either to run all pending migrations."
30
+ )
31
+
32
+ create_migrations_table_if_not_exists()
33
+ latest_version: str | None = get_last_updated_version()
34
+
35
+ all_versions: dict[str, str] = get_migration_filepaths_by_version(
36
+ directory=os.path.join(os.getcwd(), "migrations"),
37
+ version_to_start_from=latest_version,
38
+ )
39
+
40
+ if latest_version:
41
+ all_versions = dict(list(all_versions.items())[1:])
42
+
43
+ if count:
44
+ all_versions = dict(list(all_versions.items())[:count])
45
+ elif to_version:
46
+ if all_versions.get(to_version) is None:
47
+ raise ValueError(
48
+ f"The specified to_version '{to_version}' does not exist among pending migrations."
49
+ )
50
+ all_versions_list = []
51
+ for file_version, file_path in all_versions.items():
52
+ all_versions_list.append((file_version, file_path))
53
+ if file_version == to_version:
54
+ break
55
+ all_versions = dict(all_versions_list)
56
+
57
+ if not dry_run:
58
+ for version, file_path in all_versions.items():
59
+ sql_statements: list[str] = parse_upgrade_statements(file_path=file_path)
60
+ filename: str = os.path.basename(file_path)
61
+
62
+ run_migration(
63
+ sql_statements=sql_statements,
64
+ version=version,
65
+ migration_operation=MigrationOperationType.UPGRADE,
66
+ filename=filename,
67
+ )
68
+
69
+ print(f"Migration applied successfully: {filename}")
70
+
71
+ else:
72
+ process_dry_run(
73
+ version_to_filepath=all_versions,
74
+ migration_operation=MigrationOperationType.UPGRADE,
75
+ )
@@ -0,0 +1,163 @@
1
+ import os
2
+
3
+ from jetbase.core.file_parser import is_filename_format_valid, is_filename_length_valid
4
+
5
+
6
+ def _get_version_key_from_filename(filename: str) -> str:
7
+ """
8
+ Extract and normalize version key from a filename.
9
+
10
+ The function extracts the version part from a filename that follows the format:
11
+ 'V{version}__{description}.sql' where version can be like '1', '1_1', or '1.1'.
12
+
13
+ Args:
14
+ filename (str): The filename to extract version from.
15
+ Must follow pattern like 'V1__description.sql' or 'V1_1__description.sql'
16
+
17
+ Returns:
18
+ str: Normalized version string where underscores are replaced with periods.
19
+
20
+ Raises:
21
+ ValueError: If the filename doesn't follow the expected format.
22
+
23
+ Examples:
24
+ >>> _get_version_key_from_filename("V1__my_description.sql")
25
+ '1'
26
+ >>> _get_version_key_from_filename("V1_1__my_description.sql")
27
+ '1.1'
28
+ >>> _get_version_key_from_filename("V1.1__my_description.sql")
29
+ '1.1'
30
+ """
31
+ try:
32
+ version = filename.split("__")[0][1:]
33
+ except Exception:
34
+ raise (
35
+ ValueError(
36
+ "Filename must be in the following format: V1__my_description.sql, V1_1__my_description.sql, V1.1__my_description.sql"
37
+ )
38
+ )
39
+ return version.replace("_", ".")
40
+
41
+
42
+ def _convert_version_tuple_to_str(version_tuple: tuple[str, ...]) -> str:
43
+ """
44
+ Convert a version tuple to a string representation.
45
+
46
+ Args:
47
+ version_tuple (tuple[str, ...]): A tuple containing version components as strings.
48
+
49
+ Returns:
50
+ str: A string representation of the version, with components joined by periods.
51
+
52
+ Example:
53
+ >>> _convert_version_tuple_to_str(('1', '2', '3'))
54
+ '1.2.3'
55
+ """
56
+ return ".".join(version_tuple)
57
+
58
+
59
+ def convert_version_to_tuple(version: str) -> tuple[str, ...]:
60
+ """
61
+ Convert a version string to a tuple of version components.
62
+
63
+ Args:
64
+ version_str (str): A version string with components separated by periods.
65
+
66
+ Returns:
67
+ tuple[str, ...]: A tuple containing the version components as strings.
68
+
69
+ Example:
70
+ >>> convert_version_to_tuple("1.2.3")
71
+ ('1', '2', '3')
72
+ """
73
+ return tuple(version.split("."))
74
+
75
+
76
+ def get_migration_filepaths_by_version(
77
+ directory: str,
78
+ version_to_start_from: str | None = None,
79
+ end_version: str | None = None,
80
+ ) -> dict[str, str]:
81
+ """
82
+ Retrieve migration file paths organized by version number.
83
+
84
+ Walks through the specified directory to find SQL migration files and creates
85
+ a dictionary mapping version strings to their file paths. Files are validated
86
+ for proper naming format and length. Results can be filtered by version range.
87
+
88
+ Args:
89
+ directory (str): The directory path to search for SQL migration files.
90
+ version_to_start_from (str | None): Optional minimum version (inclusive).
91
+ Only files with versions >= this value are included. Defaults to None.
92
+ end_version (str | None): Optional maximum version (exclusive).
93
+ Only files with versions < this value are included. Defaults to None.
94
+
95
+ Returns:
96
+ dict[str, str]: Dictionary mapping version strings to file paths,
97
+ sorted in ascending order by version number.
98
+
99
+ Raises:
100
+ ValueError: If a filename doesn't match the required format or exceeds
101
+ the maximum length of 512 characters.
102
+
103
+ Example:
104
+ >>> get_migration_filepaths_by_version('/path/to/migrations')
105
+ {'1.0.0': '/path/to/migrations/V1_0_0__init.sql',
106
+ '1.2.0': '/path/to/migrations/V1_2_0__add_users.sql'}
107
+ >>> get_migration_filepaths_by_version('/path/to/migrations', version_to_start_from='1.1.0')
108
+ {'1.2.0': '/path/to/migrations/V1_2_0__add_users.sql'}
109
+ """
110
+ version_to_filepath_dict: dict[str, str] = {}
111
+
112
+ for root, _, files in os.walk(directory):
113
+ for filename in files:
114
+ if filename.endswith(".sql") and not is_filename_format_valid(
115
+ filename=filename
116
+ ):
117
+ raise ValueError(
118
+ f"Invalid migration filename format: {filename}. "
119
+ "Filenames must start with 'V', followed by the version number, "
120
+ "two underscores '__', a description, and end with '.sql'. "
121
+ "V<version_number>__<my_description>.sql. "
122
+ "Examples: 'V1_2_0__add_new_table.sql' or 'V1.2.0__add_new_table.sql'"
123
+ )
124
+
125
+ if filename.endswith(".sql") and not is_filename_length_valid(
126
+ filename=filename
127
+ ):
128
+ raise ValueError(
129
+ f"Migration filename too long: {filename}. "
130
+ f"Filename is currently {len(filename)} characters. "
131
+ "Filenames must not exceed 512 characters."
132
+ )
133
+
134
+ if is_filename_format_valid(filename=filename):
135
+ file_path: str = os.path.join(root, filename)
136
+ version: str = _get_version_key_from_filename(filename=filename)
137
+ version_tuple: tuple[str, ...] = convert_version_to_tuple(
138
+ version=version
139
+ )
140
+
141
+ if end_version:
142
+ if version_tuple > convert_version_to_tuple(version=end_version):
143
+ continue
144
+
145
+ if version_to_start_from:
146
+ if version_tuple >= convert_version_to_tuple(
147
+ version=version_to_start_from
148
+ ):
149
+ version_to_filepath_dict[
150
+ _convert_version_tuple_to_str(version_tuple=version_tuple)
151
+ ] = file_path
152
+
153
+ else:
154
+ version_to_filepath_dict[
155
+ _convert_version_tuple_to_str(version_tuple=version_tuple)
156
+ ] = file_path
157
+
158
+ ordered_version_to_filepath_dict: dict[str, str] = {
159
+ version: version_to_filepath_dict[version]
160
+ for version in sorted(version_to_filepath_dict.keys())
161
+ }
162
+
163
+ return ordered_version_to_filepath_dict
jetbase/enums.py ADDED
@@ -0,0 +1,6 @@
1
+ from enum import Enum
2
+
3
+
4
+ class MigrationOperationType(Enum):
5
+ UPGRADE = "upgrade"
6
+ ROLLBACK = "rollback"
jetbase/queries.py ADDED
@@ -0,0 +1,72 @@
1
+ from sqlalchemy import TextClause, text
2
+
3
+ LATEST_VERSION_QUERY: TextClause = text("""
4
+ SELECT
5
+ version
6
+ FROM
7
+ jetbase_migrations
8
+ ORDER BY
9
+ applied_at DESC
10
+ LIMIT 1
11
+ """)
12
+
13
+ CREATE_MIGRATIONS_TABLE_STMT: TextClause = text("""
14
+ CREATE TABLE IF NOT EXISTS jetbase_migrations (
15
+ version VARCHAR(255) PRIMARY KEY,
16
+ description VARCHAR(500),
17
+ filename VARCHAR(512),
18
+ order_executed INT GENERATED ALWAYS AS IDENTITY,
19
+ applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
20
+ )
21
+ """)
22
+
23
+ INSERT_VERSION_STMT: TextClause = text("""
24
+ INSERT INTO jetbase_migrations (version, description, filename)
25
+ VALUES (:version, :description, :filename)
26
+ """)
27
+
28
+ DELETE_VERSION_STMT: TextClause = text("""
29
+ DELETE FROM jetbase_migrations
30
+ WHERE version = :version
31
+ """)
32
+
33
+ LATEST_VERSIONS_QUERY: TextClause = text("""
34
+ SELECT
35
+ version
36
+ FROM
37
+ jetbase_migrations
38
+ ORDER BY
39
+ applied_at DESC
40
+ LIMIT :limit
41
+ """)
42
+
43
+ LATEST_VERSIONS_BY_STARTING_VERSION_QUERY: TextClause = text("""
44
+ SELECT
45
+ version
46
+ FROM
47
+ jetbase_migrations
48
+ WHERE applied_at >
49
+ (select applied_at from jetbase_migrations
50
+ where version = :starting_version)
51
+ ORDER BY
52
+ applied_at DESC
53
+ """)
54
+
55
+ CHECK_IF_VERSION_EXISTS_QUERY: TextClause = text("""
56
+ SELECT
57
+ COUNT(*)
58
+ FROM
59
+ jetbase_migrations
60
+ WHERE
61
+ version = :version
62
+ """)
63
+
64
+
65
+ CHECK_IF_MIGRATIONS_TABLE_EXISTS_QUERY: TextClause = text("""
66
+ SELECT EXISTS (
67
+ SELECT 1
68
+ FROM information_schema.tables
69
+ WHERE table_schema = 'public'
70
+ AND table_name = 'jetbase_migrations'
71
+ )
72
+ """)
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: jetbase
3
+ Version: 0.7.0
4
+ Summary: Jetbase is a Python database migration tool
5
+ Author-email: jaz <jaz.allibhai@gmail.com>
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: sqlalchemy>=2.0.10
9
+ Requires-Dist: typer>=0.12.3
10
+ Description-Content-Type: text/markdown
11
+
12
+ # jetbase
@@ -0,0 +1,17 @@
1
+ jetbase/config.py,sha256=-YZgS-fVW8k53azx9Ur0GjJvYCZLwOHkguMr_Ujc-mo,1731
2
+ jetbase/constants.py,sha256=X2SYgXRxj77zWB-6N764RQsJmKKUd5Wa7RaQGN6eTiQ,339
3
+ jetbase/enums.py,sha256=LFNZnWcBxvF4kIdZYdGKn2Z65ADHJs38MnGAqeEbZ_U,110
4
+ jetbase/queries.py,sha256=jZ9ei8V-dEb_8ZAwtSAFqLnIEI5rumm3KvQmw4CiZk4,1619
5
+ jetbase/cli/main.py,sha256=I0HIRGC7dmTvEhgdEBksF-p7QPoY4-CfVcm-niVhzck,1504
6
+ jetbase/core/dry_run.py,sha256=zIV4NM_OL-aR7UJlEXYysiGairMOgytnT5rg8baFSms,1469
7
+ jetbase/core/file_parser.py,sha256=Aau7iFaBoZxOgoxB-4C5XAvE_a9142kv7sJHOAANe2A,6069
8
+ jetbase/core/initialize.py,sha256=lm3FWhe1YNKCTmZix465tBGjJsl_YshhRo2ENm7iPFM,955
9
+ jetbase/core/repository.py,sha256=JrmqFkIIbICPsxnY7O3ei9kLL-0mXWFefU0QPHppcgw,5586
10
+ jetbase/core/rollback.py,sha256=e87xlI3WJaOAQD_4QdmeZIDj9a5CW7wKUxQkgIFXy3c,2385
11
+ jetbase/core/upgrade.py,sha256=GowNGAxXRD9dks8FWqSMoQPCMnMh_yP01HNEYB5Py2Q,2603
12
+ jetbase/core/version.py,sha256=mPeJEgcqA0hZMb6YMZQcfc-MGcNI2Ls_7hiH6vJnC1k,6136
13
+ jetbase-0.7.0.dist-info/METADATA,sha256=6vue-I4pBPIQRmMHY3JrxjU7j81fgL6ITMiWcwnmbOs,306
14
+ jetbase-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ jetbase-0.7.0.dist-info/entry_points.txt,sha256=-Pe7A0r8aGF-6C14hB0Ck8aEuszuH5pdadt5OWSiWwA,50
16
+ jetbase-0.7.0.dist-info/licenses/LICENSE,sha256=hyssQNKtnK32aFCk8mVR406ysMtGNP6_dZCig8zBk4Q,1065
17
+ jetbase-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jetbase = jetbase.cli.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 jaz-alli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.