Plinx 0.0.1__py3-none-any.whl → 1.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.
plinx/orm/orm.py ADDED
@@ -0,0 +1,550 @@
1
+ import inspect
2
+ import sqlite3
3
+ from typing import Generic, TypeVar
4
+
5
+ from .utils import SQLITE_TYPE_MAP
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ class Database:
11
+ """
12
+ SQLite database wrapper that provides a simple ORM interface.
13
+
14
+ The Database class is the main entry point for ORM operations in Plinx.
15
+ It handles database connections, table creation, and provides methods for
16
+ basic CRUD operations (Create, Read, Update, Delete) on Table objects.
17
+
18
+ This class uses SQLite as the underlying database engine and provides
19
+ a simplified interface that avoids writing raw SQL in most cases.
20
+
21
+ Examples:
22
+ Creating a database and defining models:
23
+
24
+ ```python
25
+ from plinx.orm import Database, Table, Column, ForeignKey
26
+
27
+ db = Database("app.db")
28
+
29
+ class User(Table):
30
+ name = Column(str)
31
+ age = Column(int)
32
+
33
+ db.create(User)
34
+
35
+ # Create a new user
36
+ john = User(name="John Doe", age=30)
37
+ db.save(john)
38
+
39
+ # Query users
40
+ all_users = db.all(User)
41
+ john = db.get(User, id=1)
42
+
43
+ # Update a user
44
+ john.age = 31
45
+ db.update(john)
46
+
47
+ # Delete a user
48
+ db.delete(john)
49
+ ```
50
+ """
51
+
52
+ def __init__(self, path: str):
53
+ """
54
+ Initialize a new database connection.
55
+
56
+ Args:
57
+ path: Path to the SQLite database file. If the file doesn't exist,
58
+ it will be created.
59
+ """
60
+ self.connection = sqlite3.Connection(path)
61
+
62
+ def create(self, table: "Table"):
63
+ """
64
+ Create a database table based on a Table subclass definition.
65
+
66
+ This method creates a table in the database with columns corresponding
67
+ to the Column and ForeignKey attributes defined on the Table subclass.
68
+ If the table already exists, this method does nothing.
69
+
70
+ Args:
71
+ table: A Table subclass with Column and/or ForeignKey attributes
72
+
73
+ Example:
74
+ ```python
75
+ class User(Table):
76
+ name = Column(str)
77
+ age = Column(int)
78
+
79
+ db.create(User)
80
+ ```
81
+ """
82
+ self.connection.execute(table._get_create_sql())
83
+
84
+ def save(self, instance: "Table"):
85
+ """
86
+ Save a Table instance to the database.
87
+
88
+ This method inserts a new row into the corresponding database table.
89
+ It automatically sets the instance's id attribute to the new row's ID.
90
+
91
+ Args:
92
+ instance: A Table instance to save
93
+
94
+ Example:
95
+ ```python
96
+ user = User(name="John Doe", age=30)
97
+ db.save(user) # user.id is now set to the new row's ID
98
+ ```
99
+ """
100
+ sql, values = instance._get_insert_sql()
101
+ cursor = self.connection.execute(sql, values)
102
+ instance._data["id"] = cursor.lastrowid
103
+ self.connection.commit()
104
+
105
+ def all(self, table: "Table"):
106
+ """
107
+ Retrieve all rows from a table.
108
+
109
+ This method selects all rows from the table corresponding to the given
110
+ Table subclass. It returns a list of instances of that class, with
111
+ attributes set to the values from the database.
112
+
113
+ Args:
114
+ table: A Table subclass to query
115
+
116
+ Returns:
117
+ List of Table instances, one for each row in the table
118
+
119
+ Example:
120
+ ```python
121
+ all_users = db.all(User)
122
+ for user in all_users:
123
+ print(f"{user.name} is {user.age} years old")
124
+ ```
125
+ """
126
+ sql, fields = table._get_select_all_sql()
127
+ rows = self.connection.execute(sql).fetchall()
128
+
129
+ result = []
130
+
131
+ for row in rows:
132
+ properties = {}
133
+ for field, value in zip(fields, row):
134
+ if field.endswith("_id"):
135
+ foreign_key = field[:-3]
136
+ foreign_table = getattr(table, foreign_key).table
137
+ properties[foreign_key] = self.get(foreign_table, id=value)
138
+ else:
139
+ properties[field] = value
140
+ result.append(table(**properties))
141
+
142
+ return result
143
+
144
+ def get(self, table: "Table", **kwargs):
145
+ """
146
+ Retrieve a single row from a table by specified criteria.
147
+
148
+ This method selects a row from the database where the specified columns
149
+ match the given values. It returns an instance of the Table subclass with
150
+ attributes set to the values from the database.
151
+
152
+ Args:
153
+ table: A Table subclass to query
154
+ **kwargs: Column-value pairs to filter by
155
+
156
+ Returns:
157
+ A Table instance corresponding to the matched row
158
+
159
+ Raises:
160
+ Exception: If no row matches the criteria
161
+
162
+ Example:
163
+ ```python
164
+ # Get user by ID
165
+ user = db.get(User, id=1)
166
+
167
+ # Get user by name
168
+ user = db.get(User, name="John Doe")
169
+ ```
170
+ """
171
+ sql, fields, params = table._get_select_where_sql(**kwargs)
172
+ row = self.connection.execute(sql, params).fetchone()
173
+
174
+ if row is None:
175
+ raise Exception(f"{table.__name__} instance with {kwargs} does not exist")
176
+
177
+ properties = {}
178
+
179
+ for field, value in zip(fields, row):
180
+ if field.endswith("_id"):
181
+ foreign_key = field[:-3]
182
+ foreign_table = getattr(table, foreign_key).table
183
+ properties[foreign_key] = self.get(foreign_table, id=value)
184
+ else:
185
+ properties[field] = value
186
+
187
+ return table(**properties)
188
+
189
+ def update(self, instance: "Table"):
190
+ """
191
+ Update an existing row in the database.
192
+
193
+ This method updates the row corresponding to the given instance with the
194
+ current values of the instance's attributes.
195
+
196
+ Args:
197
+ instance: A Table instance to update. Must have an id attribute.
198
+
199
+ Example:
200
+ ```python
201
+ user = db.get(User, id=1)
202
+ user.name = "Jane Doe"
203
+ db.update(user)
204
+ ```
205
+ """
206
+ sql, values = instance._get_update_sql()
207
+ self.connection.execute(sql, values)
208
+ self.connection.commit()
209
+
210
+ def delete(self, instance: "Table"):
211
+ """
212
+ Delete a row from the database.
213
+
214
+ This method deletes the row corresponding to the given instance.
215
+
216
+ Args:
217
+ instance: A Table instance to delete. Must have an id attribute.
218
+
219
+ Example:
220
+ ```python
221
+ user = db.get(User, id=1)
222
+ db.delete(user)
223
+ ```
224
+ """
225
+ sql, values = instance._get_delete_sql()
226
+ self.connection.execute(sql, values)
227
+ self.connection.commit()
228
+
229
+ def close(self):
230
+ """
231
+ Close the database connection.
232
+
233
+ This method closes the SQLite connection when the database is no longer
234
+ needed. It's good practice to call this method when you're done using
235
+ the database, especially in longer-running applications.
236
+ """
237
+ if self.connection:
238
+ self.connection.close()
239
+ self.connection = None
240
+
241
+ @property
242
+ def tables(self):
243
+ """
244
+ Get a list of all tables in the database.
245
+
246
+ Returns:
247
+ List of table names as strings
248
+ """
249
+ SELECT_TABLES_SQL = "SELECT name FROM sqlite_master WHERE type = 'table';"
250
+ return [x[0] for x in self.connection.execute(SELECT_TABLES_SQL).fetchall()]
251
+
252
+
253
+ class Column:
254
+ """
255
+ Define a column in a database table.
256
+
257
+ This class represents a column definition for a Table class. It stores
258
+ the column's type and can generate the corresponding SQL type.
259
+
260
+ Examples:
261
+ ```python
262
+ class User(Table):
263
+ name = Column(str) # TEXT column
264
+ age = Column(int) # INTEGER column
265
+ active = Column(bool) # INTEGER column (0=False, 1=True)
266
+ ```
267
+ """
268
+
269
+ def __init__(self, type: Generic[T]):
270
+ """
271
+ Initialize a new column.
272
+
273
+ Args:
274
+ type: Python type for the column (str, int, float, bool, bytes)
275
+ """
276
+ self.type = type
277
+
278
+ @property
279
+ def sql_type(self):
280
+ """
281
+ Get the SQL type corresponding to this column's Python type.
282
+
283
+ Returns:
284
+ SQL type string (e.g., "TEXT", "INTEGER", "REAL")
285
+ """
286
+ return SQLITE_TYPE_MAP[self.type]
287
+
288
+
289
+ class ForeignKey:
290
+ """
291
+ Define a foreign key relationship between tables.
292
+
293
+ This class represents a foreign key constraint in a database schema,
294
+ linking one Table class to another.
295
+
296
+ Examples:
297
+ ```python
298
+ class Author(Table):
299
+ name = Column(str)
300
+
301
+ class Book(Table):
302
+ title = Column(str)
303
+ author = ForeignKey(Author) # Creates author_id column
304
+ ```
305
+ """
306
+
307
+ def __init__(self, table):
308
+ """
309
+ Initialize a new foreign key.
310
+
311
+ Args:
312
+ table: The Table subclass that this foreign key references
313
+ """
314
+ self.table = table
315
+
316
+
317
+ class Table:
318
+ """
319
+ Base class for ORM models in Plinx.
320
+
321
+ This class is used as a base class for defining database tables.
322
+ Subclasses should define class attributes using Column and ForeignKey
323
+ to describe the table schema.
324
+
325
+ The Table class provides methods for generating SQL statements for
326
+ CRUD operations, which are used by the Database class.
327
+
328
+ Examples:
329
+ ```python
330
+ class User(Table):
331
+ name = Column(str)
332
+ age = Column(int)
333
+
334
+ class Post(Table):
335
+ title = Column(str)
336
+ content = Column(str)
337
+ author = ForeignKey(User)
338
+ ```
339
+ """
340
+
341
+ def __init__(self, **kwargs):
342
+ """
343
+ Initialize a new record.
344
+
345
+ Args:
346
+ **kwargs: Column values to initialize with
347
+ """
348
+ self._data = {"id": None}
349
+
350
+ for key, value in kwargs.items():
351
+ self._data[key] = value
352
+
353
+ def __getattribute__(self, key):
354
+ """
355
+ Custom attribute access for Table instances.
356
+
357
+ This method allows Table instances to access column values as attributes,
358
+ rather than accessing self._data directly.
359
+
360
+ Args:
361
+ key: Attribute name to access
362
+
363
+ Returns:
364
+ The attribute value
365
+ """
366
+ # Why use super().__getattribute__ instead of self._data[key]?
367
+ # Because otherwise it will create an infinite loop since __getattribute__ will call itself
368
+ # and will never return the value
369
+ _data = super().__getattribute__("_data")
370
+ if key in _data:
371
+ return _data[key]
372
+ return super().__getattribute__(key)
373
+
374
+ def __setattr__(self, key, value):
375
+ """
376
+ Custom attribute assignment for Table instances.
377
+
378
+ This method ensures that when setting an attribute that corresponds to
379
+ a column, the value is stored in self._data.
380
+
381
+ Args:
382
+ key: Attribute name to set
383
+ value: Value to assign
384
+ """
385
+ super().__setattr__(key, value)
386
+ if key in self._data:
387
+ self._data[key] = value
388
+
389
+ @classmethod
390
+ def _get_create_sql(cls):
391
+ """
392
+ Generate SQL for creating the table.
393
+
394
+ Returns:
395
+ SQL string for creating the table
396
+ """
397
+ CREATE_TABLE_SQL = "CREATE TABLE IF NOT EXISTS {name} ({fields});"
398
+ fields = [
399
+ "id INTEGER PRIMARY KEY AUTOINCREMENT",
400
+ ]
401
+
402
+ for name, field in inspect.getmembers(cls):
403
+ if isinstance(field, Column):
404
+ fields.append(f"{name} {field.sql_type}")
405
+ elif isinstance(field, ForeignKey):
406
+ fields.append(f"{name}_id INTEGER")
407
+
408
+ fields = ", ".join(fields)
409
+ name = cls.__name__.lower()
410
+ return CREATE_TABLE_SQL.format(name=name, fields=fields)
411
+
412
+ def _get_insert_sql(self):
413
+ """
414
+ Generate SQL for inserting a record.
415
+
416
+ Returns:
417
+ Tuple of (SQL string, parameter values list)
418
+ """
419
+ INSERT_SQL = "INSERT INTO {name} ({fields}) VALUES ({placeholders});"
420
+
421
+ cls = self.__class__
422
+ fields = []
423
+ placeholders = []
424
+ values = []
425
+
426
+ for name, field in inspect.getmembers(cls):
427
+ if isinstance(field, Column):
428
+ fields.append(name)
429
+ values.append(getattr(self, name))
430
+ placeholders.append("?")
431
+ elif isinstance(field, ForeignKey):
432
+ fields.append(name + "_id")
433
+ values.append(getattr(self, name).id)
434
+ placeholders.append("?")
435
+
436
+ fields = ", ".join(fields)
437
+ placeholders = ", ".join(placeholders)
438
+
439
+ sql = INSERT_SQL.format(
440
+ name=cls.__name__.lower(), fields=fields, placeholders=placeholders
441
+ )
442
+
443
+ return sql, values
444
+
445
+ @classmethod
446
+ def _get_select_all_sql(cls):
447
+ """
448
+ Generate SQL for selecting all records.
449
+
450
+ Returns:
451
+ Tuple of (SQL string, field names list)
452
+ """
453
+ SELECT_ALL_SQL = "SELECT {fields} FROM {name};"
454
+
455
+ fields = ["id"]
456
+
457
+ for name, field in inspect.getmembers(cls):
458
+ if isinstance(field, Column):
459
+ fields.append(name)
460
+ elif isinstance(field, ForeignKey):
461
+ fields.append(name + "_id")
462
+
463
+ return (
464
+ SELECT_ALL_SQL.format(
465
+ fields=", ".join(fields),
466
+ name=cls.__name__.lower(),
467
+ ),
468
+ fields,
469
+ )
470
+
471
+ @classmethod
472
+ def _get_select_where_sql(cls, **kwargs):
473
+ """
474
+ Generate SQL for selecting records by criteria.
475
+
476
+ Args:
477
+ **kwargs: Column-value pairs to filter by
478
+
479
+ Returns:
480
+ Tuple of (SQL string, field names list, parameter values list)
481
+ """
482
+ SELECT_WHERE_SQL = "SELECT {fields} FROM {name} WHERE {query};"
483
+
484
+ fields = ["id"]
485
+ query = []
486
+ values = []
487
+
488
+ for name, field in inspect.getmembers(cls):
489
+ if isinstance(field, Column):
490
+ fields.append(name)
491
+ elif isinstance(field, ForeignKey):
492
+ fields.append(name + "_id")
493
+
494
+ for key, value in kwargs.items():
495
+ query.append(f"{key} = ?")
496
+ values.append(value)
497
+
498
+ return (
499
+ SELECT_WHERE_SQL.format(
500
+ fields=", ".join(fields),
501
+ name=cls.__name__.lower(),
502
+ query=", ".join(query),
503
+ ),
504
+ fields,
505
+ values,
506
+ )
507
+
508
+ def _get_update_sql(self):
509
+ """
510
+ Generate SQL for updating a record.
511
+
512
+ Returns:
513
+ Tuple of (SQL string, parameter values list)
514
+ """
515
+ UPDATE_SQL = "UPDATE {name} SET {fields} WHERE id = ?;"
516
+
517
+ cls = self.__class__
518
+ fields = []
519
+ values = []
520
+
521
+ for name, field in inspect.getmembers(cls):
522
+ if isinstance(field, Column):
523
+ fields.append(name)
524
+ values.append(getattr(self, name))
525
+ elif isinstance(field, ForeignKey):
526
+ fields.append(name + "_id")
527
+ values.append(getattr(self, name).id)
528
+
529
+ values.append(getattr(self, "id"))
530
+
531
+ return (
532
+ UPDATE_SQL.format(
533
+ name=cls.__name__.lower(),
534
+ fields=", ".join([f"{field} = ?" for field in fields]),
535
+ ),
536
+ values,
537
+ )
538
+
539
+ def _get_delete_sql(self):
540
+ """
541
+ Generate SQL for deleting a record.
542
+
543
+ Returns:
544
+ Tuple of (SQL string, parameter values list)
545
+ """
546
+ DELETE_SQL = "DELETE FROM {name} WHERE id = ?;"
547
+
548
+ return DELETE_SQL.format(name=self.__class__.__name__.lower()), [
549
+ getattr(self, "id")
550
+ ]
plinx/orm/utils.py ADDED
@@ -0,0 +1,7 @@
1
+ SQLITE_TYPE_MAP = {
2
+ int: "INTEGER",
3
+ float: "REAL",
4
+ str: "TEXT",
5
+ bytes: "BLOB",
6
+ bool: "INTEGER",
7
+ }
plinx/response.py CHANGED
@@ -6,7 +6,57 @@ from webob import Response as WebObResponse
6
6
 
7
7
 
8
8
  class PlinxResponse:
9
+ """
10
+ Response class for the Plinx web framework.
11
+
12
+ This class provides a simple interface for constructing HTTP responses,
13
+ with high-level helpers for common response types like JSON and plain text.
14
+ It wraps WebOb's Response for actual WSGI compliance and output generation.
15
+
16
+ The class provides multiple ways to set response content:
17
+
18
+ 1. Set the `text` attribute for plain text responses
19
+ 2. Set the `json` attribute for JSON responses
20
+ 3. Set the `body` attribute directly for binary data
21
+
22
+ It also allows setting status codes, content types, and custom headers.
23
+
24
+ Examples:
25
+ Plain text response:
26
+ ```python
27
+ def handler(request, response):
28
+ response.text = "Hello, World!"
29
+ response.status_code = 200 # Optional, defaults to 200
30
+ ```
31
+
32
+ JSON response:
33
+ ```python
34
+ def handler(request, response):
35
+ response.json = {"message": "Hello, World!"}
36
+ # Content-Type will automatically be set to application/json
37
+ ```
38
+
39
+ Custom headers:
40
+ ```python
41
+ def handler(request, response):
42
+ response.text = "Not Found"
43
+ response.status_code = 404
44
+ response.headers["X-Custom-Header"] = "Value"
45
+ ```
46
+ """
47
+
9
48
  def __init__(self):
49
+ """
50
+ Initialize a new response object.
51
+
52
+ Sets up default values for the response attributes:
53
+ - json: None (will be serialized to JSON if set)
54
+ - text: None (will be encoded to UTF-8 if set)
55
+ - content_type: None (will be set based on response type)
56
+ - body: Empty bytes (raw response body)
57
+ - status_code: 200 (OK)
58
+ - headers: Empty dict (custom HTTP headers)
59
+ """
10
60
  self.json = None
11
61
  self.text = None
12
62
  self.content_type = None
@@ -20,10 +70,18 @@ class PlinxResponse:
20
70
  start_response: StartResponse,
21
71
  ) -> Iterable[bytes]:
22
72
  """
23
- Entrypoint for the WSGI application.
24
- :param environ: The WSGI environment.
25
- :param start_response: The WSGI callable.
26
- :return: The response body produced by the middleware.
73
+ WSGI callable interface for the response.
74
+
75
+ This makes the response object act as a WSGI application,
76
+ which is required for compatibility with WSGI servers.
77
+ It delegates the actual WSGI handling to WebOb's Response.
78
+
79
+ Args:
80
+ environ: The WSGI environment dictionary
81
+ start_response: The WSGI start_response callable
82
+
83
+ Returns:
84
+ An iterable of bytes representing the response body
27
85
  """
28
86
 
29
87
  self.set_body_and_content_type()
@@ -37,12 +95,26 @@ class PlinxResponse:
37
95
  return response(environ, start_response)
38
96
 
39
97
  def set_body_and_content_type(self):
98
+ """
99
+ Prepare the response body and content type based on the response attributes.
100
+
101
+ This method is called automatically before the response is returned.
102
+ It handles the conversion of high-level response attributes (`json`, `text`)
103
+ into the raw response body and appropriate content type.
104
+
105
+ The priority order is:
106
+ 1. If `json` is set, encode it as JSON and set content_type to application/json
107
+ 2. If `text` is set, encode it as UTF-8 and set content_type to text/plain
108
+ 3. Otherwise, use the existing `body` and `content_type`
109
+ """
40
110
  if self.json is not None:
41
111
  self.body = json.dumps(self.json).encode("UTF-8")
42
112
  self.content_type = "application/json"
43
113
  elif self.text is not None:
44
- self.body = self.text.encode("utf-8") if isinstance(self.text, str) else self.text
114
+ self.body = (
115
+ self.text.encode("utf-8") if isinstance(self.text, str) else self.text
116
+ )
45
117
  self.content_type = "text/plain"
46
118
 
47
119
  if self.content_type is not None:
48
- self.headers["Content-Type"] = self.content_type
120
+ self.headers["Content-Type"] = self.content_type