python-flashapi 0.1.2__py3-none-any.whl → 0.2.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.
Files changed (48) hide show
  1. flashapi/__init__.py +8 -7
  2. flashapi/adapters/base.py +13 -12
  3. flashapi/adapters/django.py +771 -165
  4. flashapi/adapters/fastapi.py +879 -293
  5. flashapi/adapters/flask.py +728 -232
  6. flashapi/core/__init__.py +11 -3
  7. flashapi/core/custom_routes.py +5 -5
  8. flashapi/core/pluralize.py +80 -80
  9. flashapi/core/relations.py +59 -61
  10. flashapi/core/response.py +36 -33
  11. flashapi/core/schema.py +145 -83
  12. flashapi/core/visibility.py +46 -0
  13. flashapi/django.py +5 -5
  14. flashapi/docs/openapi.py +298 -223
  15. flashapi/fastapi.py +5 -5
  16. flashapi/features/__init__.py +6 -6
  17. flashapi/features/audit.py +84 -0
  18. flashapi/features/auth.py +134 -0
  19. flashapi/features/dashboard.py +412 -0
  20. flashapi/features/export.py +143 -0
  21. flashapi/features/filtering.py +124 -33
  22. flashapi/features/pagination.py +20 -20
  23. flashapi/features/rate_limit.py +45 -0
  24. flashapi/features/search.py +25 -25
  25. flashapi/features/sorting.py +23 -21
  26. flashapi/features/webhooks.py +84 -0
  27. flashapi/features/websocket.py +129 -0
  28. flashapi/flask.py +5 -5
  29. flashapi/inspectors/__init__.py +3 -3
  30. flashapi/inspectors/base.py +13 -11
  31. flashapi/inspectors/dataclass.py +50 -39
  32. flashapi/inspectors/detect.py +54 -48
  33. flashapi/inspectors/django.py +93 -83
  34. flashapi/inspectors/pydantic.py +99 -84
  35. flashapi/inspectors/sqlalchemy.py +83 -75
  36. flashapi/storage/__init__.py +4 -4
  37. flashapi/storage/auto.py +189 -106
  38. flashapi/storage/base.py +32 -26
  39. flashapi/storage/orm.py +125 -85
  40. flashapi/storage/sqlalchemy.py +56 -13
  41. python_flashapi-0.2.0.dist-info/METADATA +314 -0
  42. python_flashapi-0.2.0.dist-info/RECORD +48 -0
  43. python_flashapi-0.2.0.dist-info/licenses/LICENSE +190 -0
  44. python_flashapi-0.2.0.dist-info/licenses/NOTICE +5 -0
  45. python_flashapi-0.1.2.dist-info/METADATA +0 -259
  46. python_flashapi-0.1.2.dist-info/RECORD +0 -39
  47. python_flashapi-0.1.2.dist-info/licenses/LICENSE +0 -21
  48. {python_flashapi-0.1.2.dist-info → python_flashapi-0.2.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,143 @@
1
+ """Export functionality — CSV (built-in), XLSX and PDF (optional deps)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ from typing import Any
8
+
9
+
10
+ def export_csv(items: list[dict[str, Any]], fields: list[str]) -> bytes:
11
+ output = io.BytesIO()
12
+ output.write(b"\xef\xbb\xbf") # UTF-8 BOM for Excel compatibility
13
+ wrapper = io.TextIOWrapper(output, encoding="utf-8", newline="")
14
+ writer = csv.DictWriter(wrapper, fieldnames=fields, extrasaction="ignore", delimiter=";")
15
+ writer.writeheader()
16
+ for item in items:
17
+ writer.writerow({k: item.get(k, "") for k in fields})
18
+ wrapper.flush()
19
+ wrapper.detach()
20
+ return output.getvalue()
21
+
22
+
23
+ def export_xlsx(items: list[dict[str, Any]], fields: list[str]) -> bytes:
24
+ try:
25
+ import openpyxl
26
+ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
27
+ from openpyxl.utils import get_column_letter
28
+ except ImportError:
29
+ msg = "openpyxl is required for XLSX export: pip install openpyxl"
30
+ raise ImportError(msg)
31
+
32
+ wb = openpyxl.Workbook()
33
+ ws = wb.active
34
+
35
+ header_font = Font(bold=True, color="FFFFFF", size=11)
36
+ header_fill = PatternFill(start_color="2563EB", end_color="2563EB", fill_type="solid")
37
+ header_alignment = Alignment(horizontal="center", vertical="center")
38
+ thin_border = Border(
39
+ left=Side(style="thin"),
40
+ right=Side(style="thin"),
41
+ top=Side(style="thin"),
42
+ bottom=Side(style="thin"),
43
+ )
44
+
45
+ for col_idx, field in enumerate(fields, 1):
46
+ cell = ws.cell(row=1, column=col_idx, value=field)
47
+ cell.font = header_font
48
+ cell.fill = header_fill
49
+ cell.alignment = header_alignment
50
+ cell.border = thin_border
51
+
52
+ for row_idx, item in enumerate(items, 2):
53
+ for col_idx, field in enumerate(fields, 1):
54
+ cell = ws.cell(row=row_idx, column=col_idx, value=item.get(field, ""))
55
+ cell.border = thin_border
56
+ cell.alignment = Alignment(vertical="center")
57
+
58
+ for col_idx, field in enumerate(fields, 1):
59
+ max_length = len(str(field))
60
+ for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx):
61
+ for cell in row:
62
+ if cell.value:
63
+ max_length = max(max_length, len(str(cell.value)))
64
+ ws.column_dimensions[get_column_letter(col_idx)].width = min(max_length + 3, 50)
65
+
66
+ ws.auto_filter.ref = ws.dimensions
67
+
68
+ output = io.BytesIO()
69
+ wb.save(output)
70
+ return output.getvalue()
71
+
72
+
73
+ def export_pdf(items: list[dict[str, Any]], fields: list[str]) -> bytes:
74
+ try:
75
+ from reportlab.lib import colors
76
+ from reportlab.lib.pagesizes import A4, landscape
77
+ from reportlab.lib.styles import getSampleStyleSheet
78
+ from reportlab.lib.units import mm
79
+ from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
80
+ except ImportError:
81
+ msg = "reportlab is required for PDF export: pip install reportlab"
82
+ raise ImportError(msg)
83
+
84
+ output = io.BytesIO()
85
+ page_size = landscape(A4)
86
+ doc = SimpleDocTemplate(
87
+ output,
88
+ pagesize=page_size,
89
+ leftMargin=10 * mm,
90
+ rightMargin=10 * mm,
91
+ topMargin=15 * mm,
92
+ bottomMargin=15 * mm,
93
+ )
94
+
95
+ styles = getSampleStyleSheet()
96
+ elements = []
97
+
98
+ title = Paragraph("Export", styles["Title"])
99
+ elements.append(title)
100
+ elements.append(Spacer(1, 5 * mm))
101
+
102
+ table_data = [fields]
103
+ for item in items:
104
+ row = [str(item.get(f, "")) if item.get(f) is not None else "" for f in fields]
105
+ table_data.append(row)
106
+
107
+ available_width = page_size[0] - 20 * mm
108
+ col_count = len(fields)
109
+ col_width = available_width / col_count if col_count else available_width
110
+
111
+ table = Table(table_data, colWidths=[col_width] * col_count, repeatRows=1)
112
+ table.setStyle(TableStyle([
113
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2563EB")),
114
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
115
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
116
+ ("FONTSIZE", (0, 0), (-1, 0), 8),
117
+ ("FONTSIZE", (0, 1), (-1, -1), 7),
118
+ ("ALIGN", (0, 0), (-1, 0), "CENTER"),
119
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
120
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#E5E7EB")),
121
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F9FAFB")]),
122
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
123
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
124
+ ("LEFTPADDING", (0, 0), (-1, -1), 4),
125
+ ("RIGHTPADDING", (0, 0), (-1, -1), 4),
126
+ ]))
127
+
128
+ elements.append(table)
129
+ doc.build(elements)
130
+ return output.getvalue()
131
+
132
+
133
+ EXPORTERS = {
134
+ "csv": export_csv,
135
+ "xlsx": export_xlsx,
136
+ "pdf": export_pdf,
137
+ }
138
+
139
+ CONTENT_TYPES = {
140
+ "csv": "text/csv",
141
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
142
+ "pdf": "application/pdf",
143
+ }
@@ -1,33 +1,124 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any
4
-
5
- RESERVED_PARAMS = {"page", "page_size", "sort", "search"}
6
-
7
-
8
- def apply_filters(
9
- items: list[dict[str, Any]],
10
- filters: dict[str, str],
11
- valid_fields: set[str],
12
- ) -> list[dict[str, Any]]:
13
- """Filter items by exact field match."""
14
- active_filters = {
15
- k: v for k, v in filters.items()
16
- if k not in RESERVED_PARAMS and k in valid_fields
17
- }
18
-
19
- if not active_filters:
20
- return items
21
-
22
- result = []
23
- for item in items:
24
- match = True
25
- for field_name, value in active_filters.items():
26
- item_value = item.get(field_name)
27
- if str(item_value) != str(value):
28
- match = False
29
- break
30
- if match:
31
- result.append(item)
32
-
33
- return result
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ RESERVED_PARAMS = {"page", "size", "sort", "search", "deleted", "expand", "format", "fields"}
6
+
7
+ OPERATORS = frozenset({
8
+ "eq", "neq", "gt", "gte", "lt", "lte",
9
+ "contains", "startswith", "endswith", "isnull", "in",
10
+ })
11
+
12
+
13
+ def _parse_filter_key(key: str) -> tuple[str, str]:
14
+ """Parse 'field.operator' into (field_name, operator). Default operator is 'eq'."""
15
+ dot = key.rfind(".")
16
+ if dot > 0 and dot < len(key) - 1:
17
+ possible_op = key[dot + 1:]
18
+ if possible_op in OPERATORS:
19
+ return key[:dot], possible_op
20
+ return key, "eq"
21
+
22
+
23
+ def _convert(value: str, item_value: Any) -> Any:
24
+ """Convert string value to the same type as the item's field value."""
25
+ if item_value is None:
26
+ return value
27
+ target_type = type(item_value)
28
+ if target_type is str:
29
+ return value
30
+ if target_type is int:
31
+ try:
32
+ return int(value)
33
+ except (ValueError, TypeError):
34
+ return value
35
+ if target_type is float:
36
+ try:
37
+ return float(value)
38
+ except (ValueError, TypeError):
39
+ return value
40
+ if target_type is bool:
41
+ return value.lower() in ("true", "1", "yes")
42
+ return value
43
+
44
+
45
+ def _match_operator(item_value: Any, op: str, filter_value: str) -> bool:
46
+ """Evaluate a single filter operator against an item's field value."""
47
+ if op == "isnull":
48
+ is_null = item_value is None
49
+ return is_null if filter_value.lower() in ("true", "1", "yes") else not is_null
50
+
51
+ if op == "in":
52
+ parts = [p.strip() for p in filter_value.split(",")]
53
+ str_val = str(item_value) if item_value is not None else ""
54
+ converted = []
55
+ for p in parts:
56
+ converted.append(_convert(p, item_value))
57
+ return item_value in converted or str_val in parts
58
+
59
+ if item_value is None:
60
+ return False
61
+
62
+ if op == "eq":
63
+ converted = _convert(filter_value, item_value)
64
+ return item_value == converted or str(item_value) == str(filter_value)
65
+
66
+ if op == "neq":
67
+ converted = _convert(filter_value, item_value)
68
+ return item_value != converted and str(item_value) != str(filter_value)
69
+
70
+ if op == "contains":
71
+ return filter_value.lower() in str(item_value).lower()
72
+
73
+ if op == "startswith":
74
+ return str(item_value).lower().startswith(filter_value.lower())
75
+
76
+ if op == "endswith":
77
+ return str(item_value).lower().endswith(filter_value.lower())
78
+
79
+ # Comparison operators
80
+ converted = _convert(filter_value, item_value)
81
+ try:
82
+ if op == "gt":
83
+ return item_value > converted
84
+ if op == "gte":
85
+ return item_value >= converted
86
+ if op == "lt":
87
+ return item_value < converted
88
+ if op == "lte":
89
+ return item_value <= converted
90
+ except TypeError:
91
+ return False
92
+
93
+ return False
94
+
95
+
96
+ def apply_filters(
97
+ items: list[dict[str, Any]],
98
+ filters: dict[str, str],
99
+ valid_fields: set[str],
100
+ ) -> list[dict[str, Any]]:
101
+ """Filter items using operators: ?field.operator=value (default operator: eq)."""
102
+ parsed_filters: list[tuple[str, str, str]] = []
103
+ for key, value in filters.items():
104
+ if key in RESERVED_PARAMS:
105
+ continue
106
+ field_name, op = _parse_filter_key(key)
107
+ if field_name in valid_fields:
108
+ parsed_filters.append((field_name, op, value))
109
+
110
+ if not parsed_filters:
111
+ return items
112
+
113
+ result = []
114
+ for item in items:
115
+ match = True
116
+ for field_name, op, value in parsed_filters:
117
+ item_value = item.get(field_name)
118
+ if not _match_operator(item_value, op, value):
119
+ match = False
120
+ break
121
+ if match:
122
+ result.append(item)
123
+
124
+ return result
@@ -1,20 +1,20 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any
4
-
5
- DEFAULT_PAGE_SIZE = 20
6
- MAX_PAGE_SIZE = 100
7
-
8
-
9
- def paginate(
10
- items: list[dict[str, Any]],
11
- page: int = 1,
12
- page_size: int = DEFAULT_PAGE_SIZE,
13
- ) -> tuple[list[dict[str, Any]], int]:
14
- """Return a page slice and total count."""
15
- page_size = min(max(1, page_size), MAX_PAGE_SIZE)
16
- page = max(1, page)
17
- total = len(items)
18
- start = (page - 1) * page_size
19
- end = start + page_size
20
- return items[start:end], total
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ DEFAULT_SIZE = 20
6
+ MAX_SIZE = 100
7
+
8
+
9
+ def paginate(
10
+ items: list[dict[str, Any]],
11
+ page: int = 0,
12
+ size: int = DEFAULT_SIZE,
13
+ ) -> tuple[list[dict[str, Any]], int]:
14
+ """Return a page slice and total count. Page is 0-indexed."""
15
+ size = min(max(1, size), MAX_SIZE)
16
+ page = max(0, page)
17
+ total = len(items)
18
+ start = page * size
19
+ end = start + size
20
+ return items[start:end], total
@@ -0,0 +1,45 @@
1
+ """Rate limiting — in-memory sliding window per IP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections import defaultdict
7
+
8
+
9
+ class RateLimiter:
10
+ """Simple in-memory rate limiter (per-IP, sliding window)."""
11
+
12
+ def __init__(self, limit: int = 100, window: int = 60) -> None:
13
+ self._limit = limit
14
+ self._window = window
15
+ self._requests: dict[str, list[float]] = defaultdict(list)
16
+
17
+ @property
18
+ def limit(self) -> int:
19
+ return self._limit
20
+
21
+ @property
22
+ def window(self) -> int:
23
+ return self._window
24
+
25
+ def check(self, key: str) -> tuple[bool, int, int]:
26
+ """Check if request is allowed.
27
+
28
+ Returns (allowed, remaining, reset_seconds).
29
+ """
30
+ now = time.time()
31
+ window_start = now - self._window
32
+
33
+ requests = self._requests[key]
34
+ self._requests[key] = [t for t in requests if t > window_start]
35
+ requests = self._requests[key]
36
+
37
+ remaining = max(0, self._limit - len(requests))
38
+ reset = int(self._window - (now - requests[0])) if requests else self._window
39
+
40
+ if len(requests) >= self._limit:
41
+ return False, 0, reset
42
+
43
+ self._requests[key].append(now)
44
+ remaining = max(0, self._limit - len(self._requests[key]))
45
+ return True, remaining, reset
@@ -1,25 +1,25 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any
4
-
5
-
6
- def apply_search(
7
- items: list[dict[str, Any]],
8
- query: str | None,
9
- searchable_fields: set[str],
10
- ) -> list[dict[str, Any]]:
11
- """Filter items where any searchable field contains the query string."""
12
- if not query:
13
- return items
14
-
15
- query_lower = query.lower()
16
- result = []
17
-
18
- for item in items:
19
- for field_name in searchable_fields:
20
- value = item.get(field_name, "")
21
- if value and query_lower in str(value).lower():
22
- result.append(item)
23
- break
24
-
25
- return result
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def apply_search(
7
+ items: list[dict[str, Any]],
8
+ query: str | None,
9
+ searchable_fields: set[str],
10
+ ) -> list[dict[str, Any]]:
11
+ """Filter items where any searchable field contains the query string."""
12
+ if not query:
13
+ return items
14
+
15
+ query_lower = query.lower()
16
+ result = []
17
+
18
+ for item in items:
19
+ for field_name in searchable_fields:
20
+ value = item.get(field_name, "")
21
+ if value and query_lower in str(value).lower():
22
+ result.append(item)
23
+ break
24
+
25
+ return result
@@ -1,21 +1,23 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any
4
-
5
-
6
- def apply_sorting(
7
- items: list[dict[str, Any]],
8
- sort: str | None,
9
- valid_fields: set[str],
10
- ) -> list[dict[str, Any]]:
11
- """Sort items by field. Prefix with - for descending."""
12
- if not sort:
13
- return items
14
-
15
- descending = sort.startswith("-")
16
- field_name = sort.lstrip("-")
17
-
18
- if field_name not in valid_fields:
19
- return items
20
-
21
- return sorted(items, key=lambda x: x.get(field_name, ""), reverse=descending)
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def apply_sorting(
7
+ items: list[dict[str, Any]],
8
+ sort: str | None,
9
+ valid_fields: set[str],
10
+ ) -> list[dict[str, Any]]:
11
+ """Sort items by field. Format: 'field,asc' or 'field,desc'."""
12
+ if not sort:
13
+ return items
14
+
15
+ parts = sort.split(",", 1)
16
+ field_name = parts[0].strip()
17
+ direction = parts[1].strip().lower() if len(parts) > 1 else "asc"
18
+ descending = direction == "desc"
19
+
20
+ if field_name not in valid_fields:
21
+ return items
22
+
23
+ return sorted(items, key=lambda x: x.get(field_name, ""), reverse=descending)
@@ -0,0 +1,84 @@
1
+ """Webhook dispatcher — async delivery with retry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+
11
+ class WebhookDispatcher:
12
+ """Dispatches webhook events to configured URLs asynchronously."""
13
+
14
+ def __init__(
15
+ self,
16
+ urls: list[str],
17
+ *,
18
+ retry_count: int = 3,
19
+ timeout: int = 10,
20
+ ) -> None:
21
+ self._urls = urls
22
+ self._retry_count = retry_count
23
+ self._timeout = timeout
24
+ self.sent = 0
25
+ self.failed = 0
26
+ self.retries = 0
27
+
28
+ def dispatch(
29
+ self,
30
+ event: str,
31
+ entity: str,
32
+ entity_id: str | int,
33
+ data: dict[str, Any],
34
+ ) -> None:
35
+ if not self._urls:
36
+ return
37
+
38
+ payload = {
39
+ "event": event,
40
+ "entity": entity,
41
+ "entityId": str(entity_id),
42
+ "data": data,
43
+ "timestamp": datetime.now(timezone.utc).isoformat(),
44
+ }
45
+ headers = {
46
+ "Content-Type": "application/json",
47
+ "X-FlashAPI-Event": event,
48
+ "X-FlashAPI-Entity": entity,
49
+ }
50
+
51
+ for url in self._urls:
52
+ thread = threading.Thread(
53
+ target=self._send_with_retry,
54
+ args=(url, payload, headers),
55
+ daemon=True,
56
+ )
57
+ thread.start()
58
+
59
+ def _send_with_retry(self, url: str, payload: dict, headers: dict) -> None:
60
+ import json
61
+ try:
62
+ import urllib.request
63
+ except ImportError:
64
+ return
65
+
66
+ data_bytes = json.dumps(payload).encode("utf-8")
67
+
68
+ for attempt in range(self._retry_count + 1):
69
+ try:
70
+ req = urllib.request.Request(
71
+ url,
72
+ data=data_bytes,
73
+ headers=headers,
74
+ method="POST",
75
+ )
76
+ with urllib.request.urlopen(req, timeout=self._timeout):
77
+ self.sent += 1
78
+ return
79
+ except Exception:
80
+ if attempt < self._retry_count:
81
+ self.retries += 1
82
+ time.sleep(2 ** attempt)
83
+ else:
84
+ self.failed += 1