python-flashapi 0.1.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.
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime, time
4
+ from decimal import Decimal
5
+ from typing import Any, get_origin, get_args
6
+ from uuid import UUID
7
+
8
+ from flashapi.core.schema import FieldSchema, FieldType, ModelSchema
9
+ from flashapi.core.pluralize import pluralize
10
+ from flashapi.inspectors.base import Inspector
11
+
12
+ TYPE_MAP: dict[type, FieldType] = {
13
+ str: FieldType.STRING,
14
+ int: FieldType.INTEGER,
15
+ float: FieldType.FLOAT,
16
+ bool: FieldType.BOOLEAN,
17
+ datetime: FieldType.DATETIME,
18
+ date: FieldType.DATE,
19
+ time: FieldType.TIME,
20
+ UUID: FieldType.UUID,
21
+ Decimal: FieldType.FLOAT,
22
+ bytes: FieldType.BINARY,
23
+ dict: FieldType.JSON,
24
+ list: FieldType.JSON,
25
+ }
26
+
27
+
28
+ class PydanticInspector(Inspector):
29
+ def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
30
+ from pydantic import BaseModel
31
+
32
+ if not issubclass(model_class, BaseModel):
33
+ raise TypeError(f"{model_class} is not a Pydantic model")
34
+
35
+ fields: list[FieldSchema] = []
36
+ fields.append(FieldSchema(name="id", type=FieldType.INTEGER, required=False, primary_key=True, auto_generated=True))
37
+
38
+ for name, field_info in model_class.model_fields.items():
39
+ field_type = self._resolve_type(field_info.annotation)
40
+ constraints = self._extract_constraints(field_info)
41
+ required = field_info.is_required()
42
+ default = field_info.default if not required else None
43
+
44
+ fields.append(FieldSchema(
45
+ name=name,
46
+ type=field_type,
47
+ required=required,
48
+ default=default,
49
+ constraints=constraints,
50
+ ))
51
+
52
+ model_name = model_class.__name__
53
+ plural_name = plural or pluralize(model_name)
54
+
55
+ return ModelSchema(name=model_name, plural=plural_name, fields=fields)
56
+
57
+ def _resolve_type(self, annotation: Any) -> FieldType:
58
+ if annotation is None:
59
+ return FieldType.STRING
60
+
61
+ origin = get_origin(annotation)
62
+ if origin is not None:
63
+ args = get_args(annotation)
64
+ non_none = [a for a in args if a is not type(None)]
65
+ if non_none:
66
+ annotation = non_none[0]
67
+ else:
68
+ return FieldType.STRING
69
+
70
+ return TYPE_MAP.get(annotation, FieldType.STRING)
71
+
72
+ def _extract_constraints(self, field_info: Any) -> dict:
73
+ constraints = {}
74
+ metadata = getattr(field_info, "metadata", [])
75
+ for m in metadata:
76
+ if hasattr(m, "max_length"):
77
+ constraints["max_length"] = m.max_length
78
+ if hasattr(m, "min_length"):
79
+ constraints["min_length"] = m.min_length
80
+ if hasattr(m, "ge"):
81
+ constraints["min_value"] = m.ge
82
+ if hasattr(m, "le"):
83
+ constraints["max_value"] = m.le
84
+ return constraints
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from flashapi.core.schema import FieldSchema, FieldType, ModelSchema, RelationSchema
4
+ from flashapi.core.pluralize import pluralize
5
+ from flashapi.inspectors.base import Inspector
6
+
7
+
8
+ def _map_sa_type(col_type) -> FieldType:
9
+ type_name = type(col_type).__name__.upper()
10
+ mapping = {
11
+ "VARCHAR": FieldType.STRING,
12
+ "STRING": FieldType.STRING,
13
+ "TEXT": FieldType.TEXT,
14
+ "INTEGER": FieldType.INTEGER,
15
+ "BIGINTEGER": FieldType.INTEGER,
16
+ "SMALLINTEGER": FieldType.INTEGER,
17
+ "FLOAT": FieldType.FLOAT,
18
+ "NUMERIC": FieldType.FLOAT,
19
+ "BOOLEAN": FieldType.BOOLEAN,
20
+ "DATE": FieldType.DATE,
21
+ "DATETIME": FieldType.DATETIME,
22
+ "TIME": FieldType.TIME,
23
+ "UUID": FieldType.UUID,
24
+ "JSON": FieldType.JSON,
25
+ "BLOB": FieldType.BINARY,
26
+ "LARGEBINARY": FieldType.BINARY,
27
+ }
28
+ return mapping.get(type_name, FieldType.STRING)
29
+
30
+
31
+ class SQLAlchemyInspector(Inspector):
32
+ def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
33
+ table = model_class.__table__
34
+ fields: list[FieldSchema] = []
35
+
36
+ for col in table.columns:
37
+ field_type = _map_sa_type(col.type)
38
+ constraints = {}
39
+
40
+ if hasattr(col.type, "length") and col.type.length:
41
+ constraints["max_length"] = col.type.length
42
+
43
+ relation = None
44
+ if col.foreign_keys:
45
+ fk = next(iter(col.foreign_keys))
46
+ target_table = fk.column.table.name
47
+ relation = RelationSchema(type="many_to_one", target=target_table)
48
+
49
+ has_default = col.default is not None or col.server_default is not None
50
+ is_auto_int = (
51
+ col.primary_key
52
+ and col.autoincrement is not False
53
+ and field_type == FieldType.INTEGER
54
+ )
55
+ auto_generated = is_auto_int or (col.primary_key and has_default)
56
+
57
+ default_value = None
58
+ if col.default and not callable(getattr(col.default, "arg", None)):
59
+ default_value = col.default.arg
60
+
61
+ fields.append(FieldSchema(
62
+ name=col.name,
63
+ type=field_type,
64
+ required=not col.nullable and not col.primary_key and not has_default,
65
+ default=default_value,
66
+ constraints=constraints,
67
+ primary_key=col.primary_key,
68
+ auto_generated=auto_generated,
69
+ relation=relation,
70
+ ))
71
+
72
+ model_name = model_class.__name__
73
+ plural_name = plural or pluralize(model_name)
74
+
75
+ return ModelSchema(name=model_name, plural=plural_name, fields=fields)
flashapi/py.typed ADDED
File without changes
@@ -0,0 +1,4 @@
1
+ from flashapi.storage.base import Storage
2
+ from flashapi.storage.auto import AutoStorage
3
+
4
+ __all__ = ["Storage", "AutoStorage"]
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from typing import Any
5
+
6
+ from flashapi.core.schema import FieldType, ModelSchema
7
+ from flashapi.storage.base import Storage
8
+
9
+ FIELD_TYPE_TO_SQL = {
10
+ FieldType.STRING: "TEXT",
11
+ FieldType.INTEGER: "INTEGER",
12
+ FieldType.FLOAT: "REAL",
13
+ FieldType.BOOLEAN: "INTEGER",
14
+ FieldType.DATE: "TEXT",
15
+ FieldType.DATETIME: "TEXT",
16
+ FieldType.TIME: "TEXT",
17
+ FieldType.UUID: "TEXT",
18
+ FieldType.JSON: "TEXT",
19
+ FieldType.TEXT: "TEXT",
20
+ FieldType.BINARY: "BLOB",
21
+ }
22
+
23
+
24
+ def _validate_identifier(name: str) -> str:
25
+ """Validate and quote a SQL identifier to prevent injection."""
26
+ import re
27
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
28
+ raise ValueError(f"Invalid SQL identifier: {name!r}")
29
+ return f'"{name}"'
30
+
31
+
32
+ class AutoStorage(Storage):
33
+ """SQLite-backed automatic storage for Pydantic/dataclass models."""
34
+
35
+ def __init__(self, database: str = "flashapi.db"):
36
+ self._db_path = database
37
+ self._conn = sqlite3.connect(database, check_same_thread=False)
38
+ self._conn.row_factory = sqlite3.Row
39
+ self._conn.execute("PRAGMA journal_mode=WAL")
40
+
41
+ def ensure_table(self, schema: ModelSchema) -> None:
42
+ table = _validate_identifier(schema.plural)
43
+ columns = []
44
+ for field in schema.fields:
45
+ col_name = _validate_identifier(field.name)
46
+ if field.primary_key:
47
+ columns.append(f"{col_name} INTEGER PRIMARY KEY AUTOINCREMENT")
48
+ else:
49
+ sql_type = FIELD_TYPE_TO_SQL.get(field.type, "TEXT")
50
+ not_null = " NOT NULL" if field.required else ""
51
+ columns.append(f"{col_name} {sql_type}{not_null}")
52
+
53
+ sql = f"CREATE TABLE IF NOT EXISTS {table} ({', '.join(columns)})"
54
+ self._conn.execute(sql)
55
+ self._conn.commit()
56
+
57
+ def create(self, table: str, data: dict[str, Any]) -> dict[str, Any]:
58
+ safe_table = _validate_identifier(table)
59
+ columns = [_validate_identifier(k) for k in data.keys()]
60
+ placeholders = ", ".join(["?"] * len(columns))
61
+ col_names = ", ".join(columns)
62
+ values = list(data.values())
63
+
64
+ cursor = self._conn.execute(
65
+ f"INSERT INTO {safe_table} ({col_names}) VALUES ({placeholders})", values
66
+ )
67
+ self._conn.commit()
68
+
69
+ item_id = cursor.lastrowid
70
+ return self.get(table, item_id)
71
+
72
+ def get(self, table: str, item_id: int | str) -> dict[str, Any] | None:
73
+ safe_table = _validate_identifier(table)
74
+ cursor = self._conn.execute(f"SELECT * FROM {safe_table} WHERE id = ?", (item_id,))
75
+ row = cursor.fetchone()
76
+ if row is None:
77
+ return None
78
+ return dict(row)
79
+
80
+ def list_all(self, table: str) -> list[dict[str, Any]]:
81
+ safe_table = _validate_identifier(table)
82
+ cursor = self._conn.execute(f"SELECT * FROM {safe_table}")
83
+ return [dict(row) for row in cursor.fetchall()]
84
+
85
+ def update(self, table: str, item_id: int | str, data: dict[str, Any]) -> dict[str, Any] | None:
86
+ if not self.get(table, item_id):
87
+ return None
88
+
89
+ safe_table = _validate_identifier(table)
90
+ set_clause = ", ".join([f"{_validate_identifier(k)} = ?" for k in data.keys()])
91
+ values = list(data.values()) + [item_id]
92
+
93
+ self._conn.execute(f"UPDATE {safe_table} SET {set_clause} WHERE id = ?", values)
94
+ self._conn.commit()
95
+ return self.get(table, item_id)
96
+
97
+ def delete(self, table: str, item_id: int | str) -> bool:
98
+ if not self.get(table, item_id):
99
+ return False
100
+ safe_table = _validate_identifier(table)
101
+ self._conn.execute(f"DELETE FROM {safe_table} WHERE id = ?", (item_id,))
102
+ self._conn.commit()
103
+ return True
104
+
105
+ def close(self) -> None:
106
+ self._conn.close()
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class Storage(ABC):
8
+ @abstractmethod
9
+ def create(self, table: str, data: dict[str, Any]) -> dict[str, Any]:
10
+ ...
11
+
12
+ @abstractmethod
13
+ def get(self, table: str, item_id: int | str) -> dict[str, Any] | None:
14
+ ...
15
+
16
+ @abstractmethod
17
+ def list_all(self, table: str) -> list[dict[str, Any]]:
18
+ ...
19
+
20
+ @abstractmethod
21
+ def update(self, table: str, item_id: int | str, data: dict[str, Any]) -> dict[str, Any] | None:
22
+ ...
23
+
24
+ @abstractmethod
25
+ def delete(self, table: str, item_id: int | str) -> bool:
26
+ ...
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from flashapi.storage.base import Storage
6
+
7
+
8
+ class DjangoORMStorage(Storage):
9
+ """Storage backend that delegates to Django's ORM."""
10
+
11
+ def __init__(self, model_class: type):
12
+ self._model = model_class
13
+
14
+ def create(self, table: str, data: dict[str, Any]) -> dict[str, Any]:
15
+ instance = self._model.objects.create(**data)
16
+ return self._to_dict(instance)
17
+
18
+ def get(self, table: str, item_id: int | str) -> dict[str, Any] | None:
19
+ try:
20
+ instance = self._model.objects.get(pk=item_id)
21
+ return self._to_dict(instance)
22
+ except self._model.DoesNotExist:
23
+ return None
24
+
25
+ def list_all(self, table: str) -> list[dict[str, Any]]:
26
+ return [self._to_dict(obj) for obj in self._model.objects.all()]
27
+
28
+ def update(self, table: str, item_id: int | str, data: dict[str, Any]) -> dict[str, Any] | None:
29
+ try:
30
+ instance = self._model.objects.get(pk=item_id)
31
+ except self._model.DoesNotExist:
32
+ return None
33
+
34
+ for key, value in data.items():
35
+ setattr(instance, key, value)
36
+ instance.save()
37
+ return self._to_dict(instance)
38
+
39
+ def delete(self, table: str, item_id: int | str) -> bool:
40
+ try:
41
+ instance = self._model.objects.get(pk=item_id)
42
+ instance.delete()
43
+ return True
44
+ except self._model.DoesNotExist:
45
+ return False
46
+
47
+ def _to_dict(self, instance) -> dict[str, Any]:
48
+
49
+ data = {}
50
+ for field in instance._meta.get_fields():
51
+ if field.many_to_many or field.one_to_many:
52
+ continue
53
+ if hasattr(field, "related_model") and field.related_model:
54
+ name = field.attname
55
+ else:
56
+ name = field.name
57
+ value = getattr(instance, name, None)
58
+ data[name] = self._serialize_value(value)
59
+ return data
60
+
61
+ def _serialize_value(self, value) -> Any:
62
+ from datetime import date, datetime, time
63
+ from decimal import Decimal
64
+ import uuid
65
+
66
+ if value is None:
67
+ return None
68
+ if isinstance(value, (str, int, float, bool)):
69
+ return value
70
+ if isinstance(value, Decimal):
71
+ return float(value)
72
+ if isinstance(value, datetime):
73
+ return value.isoformat()
74
+ if isinstance(value, date):
75
+ return value.isoformat()
76
+ if isinstance(value, time):
77
+ return value.isoformat()
78
+ if isinstance(value, uuid.UUID):
79
+ return str(value)
80
+ if hasattr(value, "field") and hasattr(value, "name"):
81
+ try:
82
+ return value.name or None
83
+ except (ValueError, AttributeError):
84
+ return None
85
+ return str(value)
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime, time
4
+ from typing import Any
5
+
6
+ from flashapi.storage.base import Storage
7
+
8
+
9
+ class SQLAlchemyStorage(Storage):
10
+ """Storage backend that delegates to a SQLAlchemy session."""
11
+
12
+ def __init__(self, session_factory, model_class: type):
13
+ self._session_factory = session_factory
14
+ self._model = model_class
15
+ self._column_types = {
16
+ col.name: col.type for col in model_class.__table__.columns
17
+ }
18
+
19
+ def _coerce_values(self, data: dict[str, Any]) -> dict[str, Any]:
20
+ """Convert string values to proper Python types based on column definitions."""
21
+ from sqlalchemy import Date, DateTime, Time, Boolean
22
+
23
+ coerced = {}
24
+ for key, value in data.items():
25
+ if value is None:
26
+ coerced[key] = None
27
+ continue
28
+
29
+ col_type = self._column_types.get(key)
30
+ if col_type is None:
31
+ coerced[key] = value
32
+ continue
33
+
34
+ if isinstance(col_type, DateTime) and isinstance(value, str):
35
+ coerced[key] = datetime.fromisoformat(value)
36
+ elif isinstance(col_type, Date) and isinstance(value, str):
37
+ coerced[key] = date.fromisoformat(value)
38
+ elif isinstance(col_type, Time) and isinstance(value, str):
39
+ coerced[key] = time.fromisoformat(value)
40
+ elif isinstance(col_type, Boolean) and not isinstance(value, bool):
41
+ coerced[key] = bool(value)
42
+ else:
43
+ coerced[key] = value
44
+
45
+ return coerced
46
+
47
+ def create(self, table: str, data: dict[str, Any]) -> dict[str, Any]:
48
+ session = self._session_factory()
49
+ try:
50
+ instance = self._model(**self._coerce_values(data))
51
+ session.add(instance)
52
+ session.commit()
53
+ session.refresh(instance)
54
+ return self._to_dict(instance)
55
+ except Exception:
56
+ session.rollback()
57
+ raise
58
+ finally:
59
+ session.close()
60
+
61
+ def get(self, table: str, item_id: int | str) -> dict[str, Any] | None:
62
+ session = self._session_factory()
63
+ try:
64
+ instance = session.get(self._model, item_id)
65
+ if instance is None:
66
+ return None
67
+ return self._to_dict(instance)
68
+ finally:
69
+ session.close()
70
+
71
+ def list_all(self, table: str) -> list[dict[str, Any]]:
72
+ session = self._session_factory()
73
+ try:
74
+ instances = session.query(self._model).all()
75
+ return [self._to_dict(obj) for obj in instances]
76
+ finally:
77
+ session.close()
78
+
79
+ def update(self, table: str, item_id: int | str, data: dict[str, Any]) -> dict[str, Any] | None:
80
+ session = self._session_factory()
81
+ try:
82
+ instance = session.get(self._model, item_id)
83
+ if instance is None:
84
+ return None
85
+ for key, value in self._coerce_values(data).items():
86
+ setattr(instance, key, value)
87
+ session.commit()
88
+ session.refresh(instance)
89
+ return self._to_dict(instance)
90
+ except Exception:
91
+ session.rollback()
92
+ raise
93
+ finally:
94
+ session.close()
95
+
96
+ def delete(self, table: str, item_id: int | str) -> bool:
97
+ session = self._session_factory()
98
+ try:
99
+ instance = session.get(self._model, item_id)
100
+ if instance is None:
101
+ return False
102
+ session.delete(instance)
103
+ session.commit()
104
+ return True
105
+ except Exception:
106
+ session.rollback()
107
+ raise
108
+ finally:
109
+ session.close()
110
+
111
+ def _to_dict(self, instance) -> dict[str, Any]:
112
+ data = {}
113
+ for col in instance.__table__.columns:
114
+ value = getattr(instance, col.name, None)
115
+ data[col.name] = self._serialize_value(value)
116
+ return data
117
+
118
+ def _serialize_value(self, value) -> Any:
119
+ from datetime import date, datetime, time
120
+ from decimal import Decimal
121
+ import uuid as uuid_mod
122
+
123
+ if value is None:
124
+ return None
125
+ if isinstance(value, (str, int, float, bool)):
126
+ return value
127
+ if isinstance(value, Decimal):
128
+ return float(value)
129
+ if isinstance(value, datetime):
130
+ return value.isoformat()
131
+ if isinstance(value, date):
132
+ return value.isoformat()
133
+ if isinstance(value, time):
134
+ return value.isoformat()
135
+ if isinstance(value, uuid_mod.UUID):
136
+ return str(value)
137
+ return str(value)