sqliter-py 0.6.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sqliter-py might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sqliter-py
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Interact with SQLite databases using Python and Pydantic
5
5
  Project-URL: Pull Requests, https://github.com/seapagan/sqliter-py/pulls
6
6
  Project-URL: Bug Tracker, https://github.com/seapagan/sqliter-py/issues
@@ -60,8 +60,7 @@ Website](https://sqliter.grantramsay.dev)
60
60
  > Also, structures like `list`, `dict`, `set` etc are not supported **at this
61
61
  > time** as field types, since SQLite does not have a native column type for
62
62
  > these. This is the **next planned enhancement**. These will need to be
63
- > `pickled` first then stored as a BLOB in the database . Also support `date`
64
- > which can be stored as a Unix timestamp in an integer field.
63
+ > `pickled` first then stored as a BLOB in the database.
65
64
  >
66
65
  > See the [TODO](TODO.md) for planned features and improvements.
67
66
 
@@ -75,6 +74,7 @@ Website](https://sqliter.grantramsay.dev)
75
74
  ## Features
76
75
 
77
76
  - Table creation based on Pydantic models
77
+ - Supports `date` and `datetime` fields. List/Dict/Set fields are planned.
78
78
  - Automatic primary key generation
79
79
  - User defined indexes on any field
80
80
  - Set any field as UNIQUE
@@ -32,8 +32,7 @@ Website](https://sqliter.grantramsay.dev)
32
32
  > Also, structures like `list`, `dict`, `set` etc are not supported **at this
33
33
  > time** as field types, since SQLite does not have a native column type for
34
34
  > these. This is the **next planned enhancement**. These will need to be
35
- > `pickled` first then stored as a BLOB in the database . Also support `date`
36
- > which can be stored as a Unix timestamp in an integer field.
35
+ > `pickled` first then stored as a BLOB in the database.
37
36
  >
38
37
  > See the [TODO](TODO.md) for planned features and improvements.
39
38
 
@@ -47,6 +46,7 @@ Website](https://sqliter.grantramsay.dev)
47
46
  ## Features
48
47
 
49
48
  - Table creation based on Pydantic models
49
+ - Supports `date` and `datetime` fields. List/Dict/Set fields are planned.
50
50
  - Automatic primary key generation
51
51
  - User defined indexes on any field
52
52
  - Set any field as UNIQUE
@@ -3,7 +3,7 @@
3
3
 
4
4
  [project]
5
5
  name = "sqliter-py"
6
- version = "0.6.0"
6
+ version = "0.7.0"
7
7
  description = "Interact with SQLite databases using Python and Pydantic"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.9"
@@ -6,8 +6,11 @@ operators and data types, which are crucial for translating between
6
6
  Pydantic models and SQLite database operations.
7
7
  """
8
8
 
9
+ import datetime
10
+
9
11
  # A dictionary mapping SQLiter filter operators to their corresponding SQL
10
12
  # operators.
13
+
11
14
  OPERATOR_MAPPING = {
12
15
  "__lt": "<",
13
16
  "__lte": "<=",
@@ -34,4 +37,6 @@ SQLITE_TYPE_MAPPING = {
34
37
  str: "TEXT",
35
38
  bool: "INTEGER", # SQLite stores booleans as integers (0 or 1)
36
39
  bytes: "BLOB",
40
+ datetime.datetime: "INTEGER", # Store as Unix timestamp
41
+ datetime.date: "INTEGER", # Store as Unix timestamp
37
42
  }
@@ -0,0 +1,100 @@
1
+ """Utility functions for SQLiter internal operations.
2
+
3
+ This module provides helper functions used across the SQLiter library,
4
+ primarily for type inference and mapping between Python and SQLite
5
+ data types. These utilities support the core functionality of model
6
+ to database schema translation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ from typing import Union
13
+
14
+ from sqliter.constants import SQLITE_TYPE_MAPPING
15
+
16
+
17
+ def infer_sqlite_type(field_type: Union[type, None]) -> str:
18
+ """Infer the SQLite column type based on the Python type.
19
+
20
+ This function maps Python types to their corresponding SQLite column
21
+ types. It's used when creating database tables to ensure that the
22
+ correct SQLite types are used for each field.
23
+
24
+ Args:
25
+ field_type: The Python type of the field, or None.
26
+
27
+ Returns:
28
+ A string representing the corresponding SQLite column type.
29
+
30
+ Note:
31
+ If the input type is None or not recognized, it defaults to 'TEXT'.
32
+ """
33
+ # If field_type is None, default to TEXT
34
+ if field_type is None:
35
+ return "TEXT"
36
+
37
+ # Map the simplified type to an SQLite type
38
+ return SQLITE_TYPE_MAPPING.get(field_type, "TEXT")
39
+
40
+
41
+ def to_unix_timestamp(value: datetime.date | datetime.datetime) -> int:
42
+ """Convert datetime or date to a Unix timestamp in UTC.
43
+
44
+ Args:
45
+ value: The datetime or date object to convert.
46
+
47
+ Returns:
48
+ An integer Unix timestamp.
49
+
50
+ Raises:
51
+ TypeError: If the value is not a datetime or date object.
52
+ """
53
+ if isinstance(value, datetime.datetime):
54
+ # If no timezone is provided, assume local time and convert to UTC
55
+ if value.tzinfo is None:
56
+ value = value.astimezone() # Convert to user's local timezone
57
+ # Convert to UTC before storing
58
+ value = value.astimezone(datetime.timezone.utc)
59
+ return int(value.timestamp())
60
+ if isinstance(value, datetime.date):
61
+ # Convert date to datetime at midnight in UTC
62
+ dt = datetime.datetime.combine(
63
+ value, datetime.time(0, 0), tzinfo=datetime.timezone.utc
64
+ )
65
+ return int(dt.timestamp())
66
+
67
+ err_msg = "Expected datetime or date object."
68
+ raise TypeError(err_msg)
69
+
70
+
71
+ def from_unix_timestamp(
72
+ value: int, to_type: type, *, localize: bool = True
73
+ ) -> datetime.date | datetime.datetime:
74
+ """Convert a Unix timestamp to datetime or date, optionally to local time.
75
+
76
+ Args:
77
+ value: The Unix timestamp as an integer.
78
+ to_type: The expected output type, either datetime or date.
79
+ localize: If True, convert the datetime to the user's local timezone.
80
+
81
+ Returns:
82
+ The corresponding datetime or date object.
83
+
84
+ Raises:
85
+ TypeError: If to_type is not datetime or date.
86
+ """
87
+ if to_type is datetime.datetime:
88
+ # Convert the Unix timestamp to UTC datetime
89
+ dt = datetime.datetime.fromtimestamp(value, tz=datetime.timezone.utc)
90
+ # Convert to local time if requested
91
+ return dt.astimezone() if localize else dt
92
+ if to_type is datetime.date:
93
+ # Convert to UTC datetime first
94
+ dt = datetime.datetime.fromtimestamp(value, tz=datetime.timezone.utc)
95
+ # Convert to local time if requested, then return the date part
96
+ dt_local = dt.astimezone() if localize else dt
97
+ return dt_local.date() # Extract the date part
98
+
99
+ err_msg = "Expected datetime or date type."
100
+ raise TypeError(err_msg)
@@ -5,7 +5,7 @@ models in SQLiter applications, and the Unique class, which is used to
5
5
  define unique constraints on model fields.
6
6
  """
7
7
 
8
- from .model import BaseDBModel
8
+ from .model import BaseDBModel, SerializableField
9
9
  from .unique import Unique
10
10
 
11
- __all__ = ["BaseDBModel", "Unique"]
11
+ __all__ = ["BaseDBModel", "Unique", "SerializableField"]
@@ -9,11 +9,13 @@ in SQLiter applications.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import datetime
12
13
  import re
13
14
  from typing import (
14
15
  Any,
15
16
  ClassVar,
16
17
  Optional,
18
+ Protocol,
17
19
  TypeVar,
18
20
  Union,
19
21
  cast,
@@ -23,9 +25,15 @@ from typing import (
23
25
 
24
26
  from pydantic import BaseModel, ConfigDict, Field
25
27
 
28
+ from sqliter.helpers import from_unix_timestamp, to_unix_timestamp
29
+
26
30
  T = TypeVar("T", bound="BaseDBModel")
27
31
 
28
32
 
33
+ class SerializableField(Protocol):
34
+ """Protocol for fields that can be serialized or deserialized."""
35
+
36
+
29
37
  class BaseDBModel(BaseModel):
30
38
  """Base model class for SQLiter database models.
31
39
 
@@ -38,6 +46,14 @@ class BaseDBModel(BaseModel):
38
46
  """
39
47
 
40
48
  pk: int = Field(0, description="The mandatory primary key of the table.")
49
+ created_at: int = Field(
50
+ default=0,
51
+ description="Unix timestamp when the record was created.",
52
+ )
53
+ updated_at: int = Field(
54
+ default=0,
55
+ description="Unix timestamp when the record was last updated.",
56
+ )
41
57
 
42
58
  model_config = ConfigDict(
43
59
  extra="ignore",
@@ -151,3 +167,50 @@ class BaseDBModel(BaseModel):
151
167
  def should_create_pk(cls) -> bool:
152
168
  """Returns True since the primary key is always created."""
153
169
  return True
170
+
171
+ @classmethod
172
+ def serialize_field(cls, value: SerializableField) -> SerializableField:
173
+ """Serialize datetime or date fields to Unix timestamp.
174
+
175
+ Args:
176
+ field_name: The name of the field.
177
+ value: The value of the field.
178
+
179
+ Returns:
180
+ An integer Unix timestamp if the field is a datetime or date.
181
+ """
182
+ if isinstance(value, (datetime.datetime, datetime.date)):
183
+ return to_unix_timestamp(value)
184
+ return value # Return value as-is for non-datetime fields
185
+
186
+ # Deserialization after fetching from the database
187
+
188
+ @classmethod
189
+ def deserialize_field(
190
+ cls,
191
+ field_name: str,
192
+ value: SerializableField,
193
+ *,
194
+ return_local_time: bool,
195
+ ) -> object:
196
+ """Deserialize fields from Unix timestamp to datetime or date.
197
+
198
+ Args:
199
+ field_name: The name of the field being deserialized.
200
+ value: The Unix timestamp value fetched from the database.
201
+ return_local_time: Flag to control whether the datetime is localized
202
+ to the user's timezone.
203
+
204
+ Returns:
205
+ A datetime or date object if the field type is datetime or date,
206
+ otherwise returns the value as-is.
207
+ """
208
+ field_type = cls.__annotations__.get(field_name)
209
+
210
+ if field_type in (datetime.datetime, datetime.date) and isinstance(
211
+ value, int
212
+ ):
213
+ return from_unix_timestamp(
214
+ value, field_type, localize=return_local_time
215
+ )
216
+ return value # Return value as-is for non-datetime fields
@@ -35,7 +35,7 @@ if TYPE_CHECKING: # pragma: no cover
35
35
  from pydantic.fields import FieldInfo
36
36
 
37
37
  from sqliter import SqliterDB
38
- from sqliter.model import BaseDBModel
38
+ from sqliter.model import BaseDBModel, SerializableField
39
39
 
40
40
  # Define a type alias for the possible value types
41
41
  FilterValue = Union[
@@ -609,14 +609,32 @@ class QueryBuilder:
609
609
  An instance of the model class populated with the row data.
610
610
  """
611
611
  if self._fields:
612
- return self.model_class.model_validate_partial(
613
- {field: row[idx] for idx, field in enumerate(self._fields)}
614
- )
615
- return self.model_class(
616
- **{
617
- field: row[idx]
618
- for idx, field in enumerate(self.model_class.model_fields)
612
+ data = {
613
+ field: self._deserialize(field, row[idx])
614
+ for idx, field in enumerate(self._fields)
619
615
  }
616
+ return self.model_class.model_validate_partial(data)
617
+
618
+ data = {
619
+ field: self._deserialize(field, row[idx])
620
+ for idx, field in enumerate(self.model_class.model_fields)
621
+ }
622
+ return self.model_class(**data)
623
+
624
+ def _deserialize(
625
+ self, field_name: str, value: SerializableField
626
+ ) -> SerializableField:
627
+ """Deserialize a field value if needed.
628
+
629
+ Args:
630
+ field_name: Name of the field being deserialized.
631
+ value: Value from the database.
632
+
633
+ Returns:
634
+ The deserialized value.
635
+ """
636
+ return self.model_class.deserialize_field(
637
+ field_name, value, return_local_time=self.db.return_local_time
620
638
  )
621
639
 
622
640
  @overload
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import logging
12
12
  import sqlite3
13
+ import time
13
14
  from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
14
15
 
15
16
  from typing_extensions import Self
@@ -51,7 +52,9 @@ class SqliterDB:
51
52
  logger (Optional[logging.Logger]): Custom logger for debug output.
52
53
  """
53
54
 
54
- def __init__(
55
+ MEMORY_DB = ":memory:"
56
+
57
+ def __init__( # noqa: PLR0913
55
58
  self,
56
59
  db_filename: Optional[str] = None,
57
60
  *,
@@ -60,6 +63,7 @@ class SqliterDB:
60
63
  debug: bool = False,
61
64
  logger: Optional[logging.Logger] = None,
62
65
  reset: bool = False,
66
+ return_local_time: bool = True,
63
67
  ) -> None:
64
68
  """Initialize a new SqliterDB instance.
65
69
 
@@ -71,12 +75,13 @@ class SqliterDB:
71
75
  logger: Custom logger for debug output.
72
76
  reset: Whether to reset the database on initialization. This will
73
77
  basically drop all existing tables.
78
+ return_local_time: Whether to return local time for datetime fields.
74
79
 
75
80
  Raises:
76
81
  ValueError: If no filename is provided for a non-memory database.
77
82
  """
78
83
  if memory:
79
- self.db_filename = ":memory:"
84
+ self.db_filename = self.MEMORY_DB
80
85
  elif db_filename:
81
86
  self.db_filename = db_filename
82
87
  else:
@@ -90,6 +95,7 @@ class SqliterDB:
90
95
  self.logger = logger
91
96
  self.conn: Optional[sqlite3.Connection] = None
92
97
  self.reset = reset
98
+ self.return_local_time = return_local_time
93
99
 
94
100
  self._in_transaction = False
95
101
 
@@ -99,6 +105,54 @@ class SqliterDB:
99
105
  if self.reset:
100
106
  self._reset_database()
101
107
 
108
+ @property
109
+ def filename(self) -> Optional[str]:
110
+ """Returns the filename of the current database or None if in-memory."""
111
+ return None if self.db_filename == self.MEMORY_DB else self.db_filename
112
+
113
+ @property
114
+ def is_memory(self) -> bool:
115
+ """Returns True if the database is in-memory."""
116
+ return self.db_filename == self.MEMORY_DB
117
+
118
+ @property
119
+ def is_autocommit(self) -> bool:
120
+ """Returns True if auto-commit is enabled."""
121
+ return self.auto_commit
122
+
123
+ @property
124
+ def is_connected(self) -> bool:
125
+ """Returns True if the database is connected, False otherwise."""
126
+ return self.conn is not None
127
+
128
+ @property
129
+ def table_names(self) -> list[str]:
130
+ """Returns a list of all table names in the database.
131
+
132
+ Temporarily connects to the database if not connected and restores
133
+ the connection state afterward.
134
+ """
135
+ was_connected = self.is_connected
136
+ if not was_connected:
137
+ self.connect()
138
+
139
+ if self.conn is None:
140
+ err_msg = "Failed to establish a database connection."
141
+ raise DatabaseConnectionError(err_msg)
142
+
143
+ cursor = self.conn.cursor()
144
+ cursor.execute(
145
+ "SELECT name FROM sqlite_master WHERE type='table' "
146
+ "AND name NOT LIKE 'sqlite_%';"
147
+ )
148
+ tables = [row[0] for row in cursor.fetchall()]
149
+
150
+ # Restore the connection state
151
+ if not was_connected:
152
+ self.close()
153
+
154
+ return tables
155
+
102
156
  def _reset_database(self) -> None:
103
157
  """Drop all user-created tables in the database."""
104
158
  with self.connect() as conn:
@@ -379,11 +433,18 @@ class SqliterDB:
379
433
  if not self._in_transaction and self.auto_commit and self.conn:
380
434
  self.conn.commit()
381
435
 
382
- def insert(self, model_instance: T) -> T:
436
+ def insert(
437
+ self, model_instance: T, *, timestamp_override: bool = False
438
+ ) -> T:
383
439
  """Insert a new record into the database.
384
440
 
385
441
  Args:
386
442
  model_instance: The instance of the model class to insert.
443
+ timestamp_override: If True, override the created_at and updated_at
444
+ timestamps with provided values. Default is False. If the values
445
+ are not provided, they will be set to the current time as
446
+ normal. Without this flag, the timestamps will always be set to
447
+ the current time, even if provided.
387
448
 
388
449
  Returns:
389
450
  The updated model instance with the primary key (pk) set.
@@ -394,8 +455,28 @@ class SqliterDB:
394
455
  model_class = type(model_instance)
395
456
  table_name = model_class.get_table_name()
396
457
 
458
+ # Always set created_at and updated_at timestamps
459
+ current_timestamp = int(time.time())
460
+
461
+ # Handle the case where timestamp_override is False
462
+ if not timestamp_override:
463
+ # Always override both timestamps with the current time
464
+ model_instance.created_at = current_timestamp
465
+ model_instance.updated_at = current_timestamp
466
+ else:
467
+ # Respect provided values, but set to current time if they are 0
468
+ if model_instance.created_at == 0:
469
+ model_instance.created_at = current_timestamp
470
+ if model_instance.updated_at == 0:
471
+ model_instance.updated_at = current_timestamp
472
+
397
473
  # Get the data from the model
398
474
  data = model_instance.model_dump()
475
+
476
+ # Serialize the data
477
+ for field_name, value in list(data.items()):
478
+ data[field_name] = model_instance.serialize_field(value)
479
+
399
480
  # remove the primary key field if it exists, otherwise we'll get
400
481
  # TypeErrors as multiple primary keys will exist
401
482
  if data.get("pk", None) == 0:
@@ -473,24 +554,27 @@ class SqliterDB:
473
554
 
474
555
  Raises:
475
556
  RecordUpdateError: If there's an error updating the record or if it
476
- is not found.
557
+ is not found.
477
558
  """
478
559
  model_class = type(model_instance)
479
560
  table_name = model_class.get_table_name()
480
-
481
561
  primary_key = model_class.get_primary_key()
482
562
 
483
- fields = ", ".join(
484
- f"{field} = ?"
485
- for field in model_class.model_fields
486
- if field != primary_key
487
- )
488
- values = tuple(
489
- getattr(model_instance, field)
490
- for field in model_class.model_fields
491
- if field != primary_key
492
- )
493
- primary_key_value = getattr(model_instance, primary_key)
563
+ # Set updated_at timestamp
564
+ current_timestamp = int(time.time())
565
+ model_instance.updated_at = current_timestamp
566
+
567
+ # Get the data and serialize any datetime/date fields
568
+ data = model_instance.model_dump()
569
+ for field_name, value in list(data.items()):
570
+ data[field_name] = model_instance.serialize_field(value)
571
+
572
+ # Remove the primary key from the update data
573
+ primary_key_value = data.pop(primary_key)
574
+
575
+ # Create the SQL using the processed data
576
+ fields = ", ".join(f"{field} = ?" for field in data)
577
+ values = tuple(data.values())
494
578
 
495
579
  update_sql = f"""
496
580
  UPDATE {table_name}
@@ -1,35 +0,0 @@
1
- """Utility functions for SQLiter internal operations.
2
-
3
- This module provides helper functions used across the SQLiter library,
4
- primarily for type inference and mapping between Python and SQLite
5
- data types. These utilities support the core functionality of model
6
- to database schema translation.
7
- """
8
-
9
- from typing import Union
10
-
11
- from sqliter.constants import SQLITE_TYPE_MAPPING
12
-
13
-
14
- def infer_sqlite_type(field_type: Union[type, None]) -> str:
15
- """Infer the SQLite column type based on the Python type.
16
-
17
- This function maps Python types to their corresponding SQLite column
18
- types. It's used when creating database tables to ensure that the
19
- correct SQLite types are used for each field.
20
-
21
- Args:
22
- field_type: The Python type of the field, or None.
23
-
24
- Returns:
25
- A string representing the corresponding SQLite column type.
26
-
27
- Note:
28
- If the input type is None or not recognized, it defaults to 'TEXT'.
29
- """
30
- # If field_type is None, default to TEXT
31
- if field_type is None:
32
- return "TEXT"
33
-
34
- # Map the simplified type to an SQLite type
35
- return SQLITE_TYPE_MAPPING.get(field_type, "TEXT")
File without changes
File without changes