deev 0.0.1__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.
@@ -0,0 +1,28 @@
1
+ # SPDX-FileCopyrightText: © 2025 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from typing import Any
5
+
6
+
7
+ class _ImmutableMixin:
8
+ """Mixin that adds a ``freeze`` operation."""
9
+ __frozen__: bool = False
10
+
11
+ def __setattr__(self, name: str, value: Any) -> None:
12
+ if getattr(self, '__frozen__', False):
13
+ raise AttributeError(f'Cannot modify frozen instance: {name}')
14
+ # Call ``super()`` so the next class in the MRO (e.g. BaseModel) can run
15
+ super().__setattr__(name, value)
16
+
17
+ def __delattr__(self, name: str) -> None:
18
+ if getattr(self, '__frozen__', False):
19
+ raise AttributeError(f'Cannot delete attribute from frozen instance: {name}')
20
+ super().__delattr__(name)
21
+
22
+ def __freeze__(self) -> Any:
23
+ """Mark the instance as immutable."""
24
+ object.__setattr__(self, '__frozen__', True)
25
+ return self
26
+
27
+
28
+ __all__ = ['_ImmutableMixin']
deev/_MigrationData.py ADDED
@@ -0,0 +1,20 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .entities import entity, field
5
+
6
+
7
+ @entity(table_name='_migrationdata')
8
+ class _MigrationData:
9
+ """Internal entity representation of ``_migrationdata`` tables used by ``deev``."""
10
+ id: int = field(
11
+ autoincrement=True,
12
+ primary_key=True
13
+ )
14
+ migration: str = field(
15
+ max=260,
16
+ sqltype='VARCVHAR(260)'
17
+ )
18
+
19
+
20
+ __all__ = ['_MigrationData']
deev/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .common.ConnectionString import ConnectionString
5
+ from .common.DbError import DbError
6
+ from .entities import (
7
+ entity,
8
+ field
9
+ )
10
+ from .utils import connect
11
+ from . import common, entities, mysql, translation, utils, validation
12
+
13
+
14
+ __version__ = '0.0.1'
15
+ __commit__ = 'cb1b8ad'
16
+ __all__ = [
17
+ '__version__', '__commit__',
18
+ 'ConnectionString',
19
+ 'DbError',
20
+ 'common',
21
+ 'connect',
22
+ 'db_migrate',
23
+ 'entities',
24
+ 'entity',
25
+ 'field',
26
+ 'mysql',
27
+ 'translation',
28
+ 'utils',
29
+ 'validation'
30
+ ]
@@ -0,0 +1,108 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+
9
+ class ConnectionString:
10
+ """
11
+ A type-safe "Connection String" representation that can parse/build constituent parts.
12
+ """
13
+
14
+ __server: Optional[str]
15
+ __database: Optional[str]
16
+ __user: Optional[str]
17
+ __password: Optional[str]
18
+ __provider: Optional[str]
19
+
20
+ def __init__(
21
+ self,
22
+ connection_str: Optional[str] = None
23
+ ):
24
+ self.__server = None
25
+ self.__database = None
26
+ self.__user = None
27
+ self.__password = None
28
+ self.__provider = None
29
+ if connection_str is not None:
30
+ self.parse(connection_str)
31
+
32
+ def __str__(self) -> str:
33
+ parts = []
34
+ if self.server is not None:
35
+ parts.append(f'Server={self.server}')
36
+ if self.database is not None:
37
+ parts.append(f'Database={self.database}')
38
+ if self.user is not None:
39
+ parts.append(f'UID={self.user}')
40
+ if self.password is not None:
41
+ parts.append(f'PWD={self.password}')
42
+ if self.provider is not None:
43
+ parts.append(f'Provider={self.provider}')
44
+ return ';'.join(parts)
45
+
46
+ @property
47
+ def server(self) -> Optional[str]:
48
+ return self.__server
49
+
50
+ @server.setter
51
+ def server(self, value: Optional[str]):
52
+ self.__server = value
53
+
54
+ @property
55
+ def database(self) -> Optional[str]:
56
+ return self.__database
57
+
58
+ @database.setter
59
+ def database(self, value: Optional[str]):
60
+ self.__database = value
61
+
62
+ @property
63
+ def user(self) -> Optional[str]:
64
+ return self.__user
65
+
66
+ @user.setter
67
+ def user(self, value: Optional[str]):
68
+ self.__user = value
69
+
70
+ @property
71
+ def password(self) -> Optional[str]:
72
+ return self.__password
73
+
74
+ @password.setter
75
+ def password(self, value: Optional[str]):
76
+ self.__password = value
77
+
78
+ @property
79
+ def provider(self) -> Optional[str]:
80
+ return self.__provider
81
+
82
+ @provider.setter
83
+ def provider(self, value: Optional[str]):
84
+ self.__provider = value
85
+
86
+ def parse(self, connectionstring: Optional[str]) -> ConnectionString:
87
+ parts = (
88
+ []
89
+ if connectionstring is None
90
+ else connectionstring.split(';')
91
+ )
92
+ for part in parts:
93
+ key, value = part.split('=')
94
+ match key.lower():
95
+ case 'server':
96
+ self.server = value
97
+ case 'database':
98
+ self.database = value
99
+ case 'uid' | 'user' | 'user id' | 'username':
100
+ self.user = value
101
+ case 'pwd' | 'password' | 'pass':
102
+ self.password = value
103
+ case 'provider':
104
+ self.provider = value
105
+ return self
106
+
107
+
108
+ __all__ = ['ConnectionString']
@@ -0,0 +1,49 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from __future__ import annotations
5
+
6
+ from types import TracebackType
7
+ from typing import Any, Literal, Optional, Protocol, runtime_checkable
8
+
9
+ from .DbCursor import DbCursor
10
+
11
+
12
+ @runtime_checkable
13
+ class DbConnection(Protocol):
14
+ """DB-API 2.0 Connection proto."""
15
+
16
+ def cursor(self, *args: Any, **kwargs: Any) -> DbCursor:
17
+ ...
18
+
19
+ def commit(self) -> None:
20
+ ...
21
+
22
+ def rollback(self) -> None:
23
+ ...
24
+
25
+ def close(self) -> None:
26
+ ...
27
+
28
+ def __enter__(self) -> DbConnection:
29
+ ...
30
+
31
+ def __exit__(self, exc_type: Optional[type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType], /) -> Literal[False]:
32
+ ...
33
+
34
+ @classmethod
35
+ def __subclasshook__(cls, subclass: type) -> bool | None: # type: ignore[override]
36
+ # if db api providers can't be bothered to follow the
37
+ # spec anyone else can't be bothered to enforce it.
38
+ required = {
39
+ name
40
+ for name in dir(cls)
41
+ if not name.startswith('_')
42
+ }
43
+ for name in required:
44
+ if name not in subclass.__dict__:
45
+ return False # pragma: no cover
46
+ return True
47
+
48
+
49
+ __all__ = ['DbConnection']
@@ -0,0 +1,12 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from typing import TypeAlias
5
+
6
+ from .DbConnection import DbConnection
7
+ from .DbTransactionContext import DbTransactionContext
8
+
9
+ DbContext: TypeAlias = DbConnection | DbTransactionContext
10
+
11
+
12
+ __all__ = ['DbContext']
@@ -0,0 +1,66 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import (
7
+ Any,
8
+ Iterable,
9
+ Optional,
10
+ Sequence,
11
+ Protocol,
12
+ runtime_checkable
13
+ )
14
+
15
+ from .DbParams import DbParams
16
+
17
+
18
+ @runtime_checkable
19
+ class DbCursor(Protocol):
20
+ """DB-API 2.0 Cursor proto."""
21
+
22
+ @property
23
+ def description(self) -> Sequence[tuple[Any, ...]]:
24
+ ...
25
+
26
+ @property
27
+ def rowcount(self) -> int:
28
+ ...
29
+
30
+ def execute(self, operation: str, params: Optional[DbParams] = ...) -> None:
31
+ ...
32
+
33
+ def executemany(self, operation: str, seq_of_params: Sequence[DbParams]) -> None:
34
+ ...
35
+
36
+ def fetchone(self) -> tuple[Any, ...] | None:
37
+ ...
38
+
39
+ def fetchmany(self, size: int = ...) -> list[tuple[Any, ...]]:
40
+ ...
41
+
42
+ def fetchall(self) -> list[tuple[Any, ...]]:
43
+ ...
44
+
45
+ def close(self) -> None:
46
+ ...
47
+
48
+ def __iter__(self) -> Iterable[tuple[Any, ...]]:
49
+ ...
50
+
51
+ @classmethod
52
+ def __subclasshook__(cls, subclass: type) -> bool | None: # type: ignore[override]
53
+ # if db api providers can't be bothered to follow the
54
+ # spec anyone else can't be bothered to enforce it.
55
+ required = {
56
+ name
57
+ for name in dir(cls)
58
+ if not name.startswith('_')
59
+ }
60
+ for name in required:
61
+ if name not in subclass.__dict__:
62
+ return False # pragma: no cover
63
+ return True
64
+
65
+
66
+ __all__ = ['DbCursor']
deev/common/DbError.py ADDED
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from typing import final
5
+
6
+
7
+ @final
8
+ class DbError(Exception):
9
+ """Exception raised when a DB operation fails."""
10
+
11
+ def __init__(self, reason: str) -> None:
12
+ self.reason = reason
13
+ super().__init__(reason)
14
+
15
+ def __repr__(self) -> str:
16
+ return (f'{self.__class__.__name__}(reason={self.reason!r})')
17
+
18
+ def __str__(self) -> str:
19
+ return self.__repr__()
@@ -0,0 +1,132 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import glob
5
+ import importlib
6
+ import importlib.util
7
+ import logging
8
+ import os
9
+ from pathlib import Path
10
+ from types import ModuleType
11
+ from typing import Optional
12
+
13
+ from .._MigrationData import _MigrationData
14
+ from .ConnectionString import ConnectionString
15
+ from .DbError import DbError
16
+ from .DbTableAdapter import DbTableAdapter
17
+
18
+
19
+ class DbMigrator:
20
+ """
21
+ Performs database changes for a set of migration scripts.
22
+
23
+ Migration scripts can be scanned from a path, or an explicit set of script paths may be provided.
24
+ """
25
+
26
+ __connectionstring: ConnectionString
27
+ __logger: logging.Logger
28
+
29
+ def __init__(self, connectionstring: ConnectionString):
30
+ self.__logger = logging.getLogger(__name__)
31
+ self.__connectionstring = connectionstring
32
+
33
+ def __get_or_create_migrations_table(self) -> DbTableAdapter:
34
+ from ..utils import get_table_adapter
35
+ table_adapter = get_table_adapter(_MigrationData, self.__connectionstring)
36
+ table_adapter.create_table()
37
+ return table_adapter
38
+
39
+ def __load_migration(self, path: str) -> ModuleType:
40
+ """Load a Python file as a module given its absolute file path."""
41
+ module_name = os.path.splitext(os.path.basename(path))[0]
42
+ spec = importlib.util.spec_from_file_location(module_name, path)
43
+ if spec is None or spec.loader is None: # pragma: no cover
44
+ raise ImportError(f'Cannot create spec for {path}')
45
+ module = importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(module) # type: ignore[arg-type]
47
+ return module
48
+
49
+ def apply(self, migrations_path: Path | str, stop_at: Optional[str] = None) -> None:
50
+ from ..utils import create_database, get_transaction_context
51
+ create_database(self.__connectionstring)
52
+ if isinstance(migrations_path, str):
53
+ migrations_path = Path(migrations_path)
54
+ if not os.path.exists(migrations_path):
55
+ self.__logger.warn(f'Migrations path does not exist: {migrations_path}')
56
+ return
57
+ available_migrations = sorted(glob.glob(os.path.join(migrations_path, '*.py')))
58
+ if len(available_migrations) == 0:
59
+ self.__logger.warn(f'Migrations path does not contain migrations: {migrations_path}')
60
+ return
61
+ migrations_table = self.__get_or_create_migrations_table()
62
+ applied_migrations = dict[str, int]({
63
+ e.migration: e.id
64
+ for e in migrations_table.query(orderby='migration')
65
+ })
66
+ skipped_migration_count = 0
67
+ applied_migration_count = 0
68
+ for migration_filepath in available_migrations:
69
+ migration_name = os.path.splitext(os.path.basename(migration_filepath))[0]
70
+ if migration_name not in applied_migrations:
71
+ self.__logger.info(f'..apply migration "{migration_name}"')
72
+ migration_module = self.__load_migration(migration_filepath)
73
+ migration_func = getattr(migration_module, 'apply', None)
74
+ if migration_func is not None:
75
+ with get_transaction_context(self.__connectionstring) as db_transaction:
76
+ migration_func(db_transaction)
77
+ migrations_table.create(_MigrationData(migration=migration_name)) # type: ignore[call-arg]
78
+ migrations_table.commit()
79
+ applied_migration_count += 1
80
+ else:
81
+ raise DbError(f'Invalid migration "{migration_name}", missing `apply(...)` call.')
82
+ else:
83
+ self.__logger.info(f'..skipped migration "{migration_name}" (already applied.)')
84
+ skipped_migration_count += 1
85
+ if stop_at is not None and migration_name == stop_at:
86
+ self.__logger.info(f'..stopping at "{migration_name}", as instructed.')
87
+ break
88
+ self.__logger.info(f'Migrations applied {applied_migration_count}, skipped {skipped_migration_count}, available {len(available_migrations)}.')
89
+
90
+ def undo(self, migrations_path: Path | str, stop_at: Optional[str] = None) -> None:
91
+ from ..utils import create_database, get_transaction_context
92
+ create_database(self.__connectionstring)
93
+ if isinstance(migrations_path, str):
94
+ migrations_path = Path(migrations_path)
95
+ if not os.path.exists(migrations_path):
96
+ self.__logger.warn(f'Migrations path does not exist: {migrations_path}')
97
+ return
98
+ available_migrations = sorted(glob.glob(os.path.join(migrations_path, '*.py')), reverse=True)
99
+ if len(available_migrations) == 0:
100
+ self.__logger.warn(f'Migrations path does not contain migrations: {migrations_path}')
101
+ return
102
+ migrations_table = self.__get_or_create_migrations_table()
103
+ applied_migrations = dict[str, int]({
104
+ e.migration: e.id
105
+ for e in migrations_table.query(orderby='migration DESC')
106
+ })
107
+ skipped_migration_count = 0
108
+ applied_migration_count = 0
109
+ for migration_filepath in available_migrations:
110
+ migration_name = os.path.splitext(os.path.basename(migration_filepath))[0]
111
+ if migration_name in applied_migrations:
112
+ self.__logger.info(f'..undo migration "{migration_name}"')
113
+ migration_module = self.__load_migration(migration_filepath)
114
+ migration_func = getattr(migration_module, 'undo', None)
115
+ if migration_func is not None:
116
+ with get_transaction_context(self.__connectionstring) as db_transaction:
117
+ migration_func(db_transaction)
118
+ migrations_table.delete(id=applied_migrations.get(migration_name, 0))
119
+ migrations_table.commit()
120
+ applied_migration_count += 1
121
+ else:
122
+ raise DbError(f'Invalid migration "{migration_name}", missing `undo(...)` call.')
123
+ else:
124
+ self.__logger.info(f'..skipped migration "{migration_name}" (not applied.)')
125
+ skipped_migration_count += 1
126
+ if stop_at is not None and migration_name == stop_at:
127
+ self.__logger.info(f'..stopping at "{migration_name}", as instructed.')
128
+ break
129
+ self.__logger.info(f'Migrations undone {applied_migration_count}, skipped {skipped_migration_count}, available {len(available_migrations)}.')
130
+
131
+
132
+ __all__ = ['DbMigrator']
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from typing import Any, TypeAlias
5
+
6
+ DbParams: TypeAlias = tuple[Any, ...] | list[Any] | dict[str, Any]
7
+
8
+
9
+ __all__ = ['DbParams']
@@ -0,0 +1,77 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import (
7
+ Any,
8
+ Generator,
9
+ Optional,
10
+ Protocol,
11
+ TypeVar,
12
+ runtime_checkable
13
+ )
14
+
15
+ from .DbParams import DbParams
16
+
17
+ TEntity = TypeVar('TEntity')
18
+
19
+
20
+ @runtime_checkable
21
+ class DbTableAdapter(Protocol[TEntity]):
22
+
23
+ def create_table(self) -> None:
24
+ """Utility method for creating the target table."""
25
+ ...
26
+
27
+ def commit(self) -> None:
28
+ ...
29
+
30
+ def rollback(self) -> None:
31
+ ...
32
+
33
+ def create(self, entity: TEntity) -> dict[str, Any]:
34
+ """
35
+ Creates a new record in the specified table with the provided keyword arguments.
36
+
37
+ :param kwargs: A dictionary containing the column names and their corresponding values for the new record.
38
+ :type kwargs: dict[str, Any]
39
+ :return: The newly created record's ID if successful, otherwise None.
40
+ :rtype: Any
41
+ """
42
+ ...
43
+
44
+ def read(self, **kwargs: Any) -> TEntity | None:
45
+ """
46
+ Reads a record from the specified table with the primary key represented by `kwargs`.
47
+
48
+ :param kwargs: The primary key.
49
+ :type kwargs: dict[str, Any]
50
+ :return: A dictionary containing the column names and their corresponding values for the specified record, or None if no such record exists.
51
+ :rtype: dict[str, Any] | None
52
+ """
53
+ ...
54
+
55
+ def update(self, entity: TEntity) -> None:
56
+ ...
57
+
58
+ def delete(self, **kwargs: Any) -> None:
59
+ ...
60
+
61
+ def exists(self, **kwargs: Any) -> bool:
62
+ ...
63
+
64
+ def upsert(self, entity: TEntity) -> dict[str, Any]:
65
+ ...
66
+
67
+ def query(
68
+ self,
69
+ where: Optional[str] = ...,
70
+ params: Optional[DbParams] = ...,
71
+ orderby: Optional[str] = ...,
72
+ limit: Optional[int] = ...
73
+ ) -> Generator[TEntity, None, None]:
74
+ ...
75
+
76
+
77
+ __all__ = ['DbTableAdapter']
@@ -0,0 +1,83 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from __future__ import annotations
5
+
6
+ from types import TracebackType
7
+ from typing import (
8
+ Any,
9
+ Generator,
10
+ Optional,
11
+ Protocol,
12
+ runtime_checkable
13
+ )
14
+
15
+ from .DbConnection import DbConnection
16
+ from .DbCursor import DbCursor
17
+ from .DbParams import DbParams
18
+
19
+
20
+ @runtime_checkable
21
+ class DbTransactionContext(Protocol):
22
+
23
+ def __init__(self, connection: DbConnection) -> None:
24
+ ...
25
+
26
+ def __del__(self) -> None:
27
+ ...
28
+
29
+ def __enter__(self) -> DbTransactionContext:
30
+ ...
31
+
32
+ def __exit__(
33
+ self,
34
+ exc_type: Optional[type[BaseException]] = None,
35
+ exc_value: Optional[BaseException] = None,
36
+ traceback: Optional[TracebackType] = None
37
+ ) -> bool:
38
+ ...
39
+
40
+ @property
41
+ def connection(self) -> DbConnection:
42
+ ...
43
+
44
+ def cursor(self) -> DbCursor:
45
+ ...
46
+
47
+ def commit(self) -> None:
48
+ ...
49
+
50
+ def execute(self, sql: str, params: Optional[DbParams] = ...) -> DbCursor:
51
+ ...
52
+
53
+ def execute_script(self, sql: str) -> None:
54
+ ...
55
+
56
+ def execute_nonquery(self, sql: str, params: Optional[DbParams] = ...) -> None:
57
+ ...
58
+
59
+ def execute_reader(self, sql: str, params: Optional[DbParams] = ...) -> Generator[tuple[Any, ...], None, None]:
60
+ ...
61
+
62
+ def execute_scalar(self, sql: str, params: Optional[DbParams] = ...) -> Any:
63
+ ...
64
+
65
+ def rollback(self) -> None:
66
+ ...
67
+
68
+ @classmethod
69
+ def __subclasshook__(cls, subclass: type) -> bool | None: # type: ignore[override]
70
+ # if db api providers can't be bothered to follow the
71
+ # spec anyone else can't be bothered to enforce it.
72
+ required = {
73
+ name
74
+ for name in dir(cls)
75
+ if not name.startswith('_')
76
+ }
77
+ for name in required:
78
+ if name not in subclass.__dict__:
79
+ return False # pragma: no cover
80
+ return True
81
+
82
+
83
+ __all__ = ['DbTransactionContext']
@@ -0,0 +1,17 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from typing import Protocol, runtime_checkable
5
+
6
+
7
+ @runtime_checkable
8
+ class DbTypeMapper(Protocol):
9
+
10
+ def get_sqltype(self, field_name: str) -> str:
11
+ """
12
+ Get the SQL type (string) needed to represent an entity field in the underlying table.
13
+
14
+ :param field_spec: The "Entity Field Spec".
15
+ :return: The SQL type string.
16
+ """
17
+ ...
@@ -0,0 +1,27 @@
1
+ # SPDX-FileCopyrightText: © 2023 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .ConnectionString import ConnectionString
5
+ from .DbConnection import DbConnection
6
+ from .DbCursor import DbCursor
7
+ from .DbContext import DbContext
8
+ from .DbError import DbError
9
+ from .DbMigrator import DbMigrator
10
+ from .DbParams import DbParams
11
+ from .DbTableAdapter import DbTableAdapter
12
+ from .DbTransactionContext import DbTransactionContext
13
+ from .DbTypeMapper import DbTypeMapper
14
+
15
+
16
+ __all__ = [
17
+ 'ConnectionString',
18
+ 'DbConnection',
19
+ 'DbCursor',
20
+ 'DbContext',
21
+ 'DbError',
22
+ 'DbMigrator',
23
+ 'DbParams',
24
+ 'DbTableAdapter',
25
+ 'DbTransactionContext',
26
+ 'DbTypeMapper'
27
+ ]