sagnos 0.1.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.
sagnos-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: sagnos
3
+ Version: 0.1.0
4
+ Summary: The Spring Boot of Python for Flutter
5
+ Author: Siddhardh
6
+ Keywords: flutter,dart,backend,framework
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: fastapi>=0.110.0
10
+ Requires-Dist: uvicorn[standard]>=0.29.0
11
+ Requires-Dist: pydantic>=2.0.0
12
+ Requires-Dist: jinja2>=3.1.0
13
+ Requires-Dist: requests>=2.31.0
14
+ Requires-Dist: websockets>=12.0
15
+ Requires-Dist: typer>=0.12.0
16
+ Requires-Dist: rich>=13.0.0
17
+ Requires-Dist: watchfiles>=0.21.0
18
+ Requires-Dist: psutil>=5.9.0
sagnos-0.1.0/README.md ADDED
File without changes
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sagnos"
7
+ version = "0.1.0"
8
+ description = "The Spring Boot of Python for Flutter"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Siddhardh" }]
12
+ keywords = ["flutter", "dart", "backend", "framework"]
13
+
14
+ dependencies = [
15
+ "fastapi>=0.110.0",
16
+ "uvicorn[standard]>=0.29.0",
17
+ "pydantic>=2.0.0",
18
+ "jinja2>=3.1.0",
19
+ "requests>=2.31.0",
20
+ "websockets>=12.0",
21
+ "typer>=0.12.0",
22
+ "rich>=13.0.0",
23
+ "watchfiles>=0.21.0",
24
+ "psutil>=5.9.0",
25
+ ]
26
+
27
+ [project.scripts]
28
+ sagnos = "sagnos.cli:app"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["."]
32
+ include = ["sagnos*"]
33
+
34
+ [tool.pytest.ini_options]
35
+ pythonpath = ["."]
@@ -0,0 +1,15 @@
1
+ from .core import (
2
+ expose, model, stream,
3
+ SagnosError, NotFoundError,
4
+ ValidationError_, AuthError, ForbiddenError,
5
+ )
6
+ from .server import SagnosApp, SagnosAuth
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "expose", "model", "stream",
12
+ "SagnosApp", "SagnosAuth",
13
+ "SagnosError", "NotFoundError",
14
+ "ValidationError_", "AuthError", "ForbiddenError",
15
+ ]
@@ -0,0 +1,354 @@
1
+ """
2
+ sagnos/codegen.py
3
+ Reads the schema and generates Dart files.
4
+ This is what runs when you do: sagnos generate
5
+ """
6
+
7
+ from pathlib import Path
8
+ from jinja2 import Environment, BaseLoader
9
+ import requests
10
+
11
+
12
+ # ─── Dart Templates ───────────────────────────────────────────────────────────
13
+
14
+ MODELS_TEMPLATE = """// AUTO-GENERATED BY SAGNOS — DO NOT EDIT
15
+ // Run `sagnos generate` to regenerate
16
+ // Schema hash: {{ schema_hash }}
17
+
18
+ {% for model in models %}
19
+ class {{ model.name }} {
20
+ {% for f in model.fields %}
21
+ final {{ f.dart_type }} {{ f.dart_name }};
22
+ {% endfor %}
23
+
24
+ const {{ model.name }}({
25
+ {% for f in model.fields %}
26
+ {% if f.optional %}this.{{ f.dart_name }},
27
+ {% else %}required this.{{ f.dart_name }},
28
+ {% endif %}
29
+ {% endfor %}
30
+ });
31
+
32
+ factory {{ model.name }}.fromJson(Map<String, dynamic> json) {
33
+ return {{ model.name }}(
34
+ {% for f in model.fields %}
35
+ {{ f.dart_name }}: {{ f.from_json }},
36
+ {% endfor %}
37
+ );
38
+ }
39
+
40
+ Map<String, dynamic> toJson() {
41
+ return {
42
+ {% for f in model.fields %}
43
+ '{{ f.name }}': {{ f.dart_name }},
44
+ {% endfor %}
45
+ };
46
+ }
47
+
48
+ {{ model.name }} copyWith({
49
+ {% for f in model.fields %}
50
+ {% if f.optional %}
51
+ {{ f.dart_type }} {{ f.dart_name }},
52
+ {% else %}
53
+ {{ f.dart_type }}? {{ f.dart_name }},
54
+ {% endif %}
55
+ {% endfor %}
56
+ }) {
57
+ return {{ model.name }}(
58
+ {% for f in model.fields %}
59
+ {{ f.dart_name }}: {{ f.dart_name }} ?? this.{{ f.dart_name }},
60
+ {% endfor %}
61
+ );
62
+ }
63
+
64
+ @override
65
+ String toString() {
66
+ return '{{ model.name }}({% for f in model.fields %}{{ f.dart_name }}: ${{ f.dart_name }}{% if not loop.last %}, {% endif %}{% endfor %})';
67
+ }
68
+ }
69
+ {% endfor %}
70
+ """
71
+
72
+ CLIENT_TEMPLATE = """// AUTO-GENERATED BY SAGNOS — DO NOT EDIT
73
+ // Run `sagnos generate` to regenerate
74
+ // Schema hash: {{ schema_hash }}
75
+
76
+ import 'dart:convert';
77
+ import 'package:http/http.dart' as http;
78
+ import 'models.dart';
79
+ import 'sagnos_exception.dart';
80
+
81
+ class SagnosClient {
82
+ final String baseUrl;
83
+ String? _token;
84
+
85
+ SagnosClient({
86
+ this.baseUrl = '{{ base_url }}',
87
+ String? token,
88
+ }) : _token = token;
89
+
90
+ void setToken(String token) => _token = token;
91
+ void clearToken() => _token = null;
92
+
93
+ Map<String, String> get _headers => {
94
+ 'Content-Type': 'application/json',
95
+ if (_token != null) 'Authorization': 'Bearer $_token',
96
+ };
97
+
98
+ // [FIX-3] Call this in main() to detect schema drift
99
+ Future<void> checkSchemaDrift() async {
100
+ try {
101
+ final uri = Uri.parse('$baseUrl/sagnos/schema-version');
102
+ final response = await http.get(uri, headers: _headers);
103
+ if (response.statusCode != 200) return;
104
+ final data = json.decode(response.body);
105
+ const clientHash = '{{ schema_hash }}';
106
+ final serverHash = data['schema_hash'] as String?;
107
+ if (serverHash != null && serverHash != clientHash) {
108
+ print('[Sagnos] Schema drift detected! Run sagnos generate.');
109
+ }
110
+ } catch (_) {}
111
+ }
112
+
113
+ Future<dynamic> _get(String path, [Map<String, String>? query]) async {
114
+ final uri = Uri.parse('$baseUrl$path').replace(queryParameters: query);
115
+ final response = await http.get(uri, headers: _headers);
116
+ return _handle(response);
117
+ }
118
+
119
+ Future<dynamic> _post(String path, Map<String, dynamic> body) async {
120
+ final uri = Uri.parse('$baseUrl$path');
121
+ final response = await http.post(
122
+ uri,
123
+ headers: _headers,
124
+ body: json.encode(body),
125
+ );
126
+ return _handle(response);
127
+ }
128
+
129
+ dynamic _handle(http.Response response) {
130
+ final decoded = json.decode(response.body) as Map<String, dynamic>;
131
+ if (response.statusCode >= 200 && response.statusCode < 300) {
132
+ return decoded['data'];
133
+ }
134
+ throw SagnosException(
135
+ statusCode: response.statusCode,
136
+ errorCode: decoded['error_code'] ?? 'UNKNOWN',
137
+ message: decoded['message'] ?? 'Unknown error',
138
+ detail: decoded['detail'],
139
+ );
140
+ }
141
+
142
+ // ── Generated Methods ─────────────────────────────────────────────────────
143
+
144
+ {% for ep in endpoints %}
145
+ {% if ep.deprecated %}@Deprecated('Deprecated on the backend.'){% endif %}
146
+ /// {{ ep.docstring or ep.dart_method }}
147
+ Future<{{ ep.return_dart }}> {{ ep.dart_method }}({% for p in ep.params %}{{ p.dart_type }} {{ p.name }}{% if not loop.last %}, {% endif %}{% endfor %}) async {
148
+ {% if ep.method == 'GET' %}
149
+ final data = await _get('{{ ep.path }}'{% if ep.params %}, { {% for p in ep.params %}'{{ p.name }}': {{ p.name }}.toString(){% if not loop.last %}, {% endif %}{% endfor %} }{% endif %});
150
+ {% else %}
151
+ final data = await _post('{{ ep.path }}', { {% for p in ep.params %}'{{ p.name }}': {{ p.name }}{% if not loop.last %}, {% endif %}{% endfor %} });
152
+ {% endif %}
153
+ {% set rt = ep.return_dart %}
154
+ {% if rt == 'void' or rt == 'dynamic' %}
155
+ return data;
156
+ {% elif rt == 'String' or rt == 'String?' %}
157
+ return data as {{ rt }};
158
+ {% elif rt == 'int' or rt == 'int?' %}
159
+ return data as int;
160
+ {% elif rt == 'double' or rt == 'double?' %}
161
+ return (data as num).toDouble();
162
+ {% elif rt == 'bool' or rt == 'bool?' %}
163
+ return data as bool;
164
+ {% elif rt == 'DateTime' or rt == 'DateTime?' %}
165
+ return {% if ep.return_optional %}data == null ? null : {% endif %}DateTime.parse(data as String);
166
+ {% elif rt.startswith('List<') %}
167
+ {% set inner = rt[5:-1] %}
168
+ {% if inner in ['int', 'double', 'String', 'bool'] %}
169
+ return (data as List).cast<{{ inner }}>();
170
+ {% else %}
171
+ return (data as List).map((e) => {{ inner }}.fromJson(e as Map<String, dynamic>)).toList();
172
+ {% endif %}
173
+ {% elif rt.endswith('?') %}
174
+ return data == null ? null : {{ rt[:-1] }}.fromJson(data as Map<String, dynamic>);
175
+ {% else %}
176
+ return {{ rt }}.fromJson(data as Map<String, dynamic>);
177
+ {% endif %}
178
+ }
179
+
180
+ {% endfor %}
181
+ }
182
+ """
183
+
184
+ EXCEPTION_TEMPLATE = """// AUTO-GENERATED BY SAGNOS — DO NOT EDIT
185
+
186
+ class SagnosException implements Exception {
187
+ final int statusCode;
188
+ final String errorCode;
189
+ final String message;
190
+ final dynamic detail;
191
+
192
+ const SagnosException({
193
+ required this.statusCode,
194
+ required this.errorCode,
195
+ required this.message,
196
+ this.detail,
197
+ });
198
+
199
+ bool get isNotFound => errorCode == 'NOT_FOUND';
200
+ bool get isUnauthorized => errorCode == 'UNAUTHORIZED';
201
+ bool get isForbidden => errorCode == 'FORBIDDEN';
202
+ bool get isValidation => errorCode == 'VALIDATION_ERROR';
203
+ bool get isInternal => errorCode == 'INTERNAL_ERROR';
204
+
205
+ @override
206
+ String toString() => 'SagnosException[$errorCode $statusCode]: $message';
207
+ }
208
+ """
209
+
210
+ STREAM_TEMPLATE = """// AUTO-GENERATED BY SAGNOS — DO NOT EDIT
211
+
212
+ import 'dart:async';
213
+ import 'dart:convert';
214
+ import 'dart:io';
215
+ import 'models.dart';
216
+ import 'sagnos_exception.dart';
217
+
218
+ class SagnosStream<T> {
219
+ final String url;
220
+ final T Function(Map<String, dynamic>) fromJson;
221
+ final String? token;
222
+
223
+ WebSocket? _socket;
224
+ StreamController<T>? _controller;
225
+ bool _disposed = false;
226
+
227
+ SagnosStream({
228
+ required this.url,
229
+ required this.fromJson,
230
+ this.token,
231
+ });
232
+
233
+ Stream<T> get stream {
234
+ _controller ??= StreamController<T>.broadcast(
235
+ onListen: _connect,
236
+ onCancel: _disconnect,
237
+ );
238
+ return _controller!.stream;
239
+ }
240
+
241
+ Future<void> _connect() async {
242
+ try {
243
+ _socket = await WebSocket.connect(url);
244
+ _socket!.listen(
245
+ (raw) {
246
+ if (_disposed) return;
247
+ try {
248
+ final decoded = json.decode(raw as String) as Map<String, dynamic>;
249
+ if (decoded['status'] == 'error') {
250
+ _controller?.addError(SagnosException(
251
+ statusCode: decoded['status_code'] ?? 500,
252
+ errorCode: decoded['error_code'] ?? 'WS_ERROR',
253
+ message: decoded['message'] ?? 'Stream error',
254
+ ));
255
+ } else {
256
+ _controller?.add(fromJson(decoded['data']));
257
+ }
258
+ } catch (e) {
259
+ _controller?.addError(e);
260
+ }
261
+ },
262
+ onError: (e) => _controller?.addError(e),
263
+ onDone: () { if (!_disposed) _reconnect(); },
264
+ );
265
+ } catch (e) {
266
+ _controller?.addError(e);
267
+ await Future.delayed(const Duration(seconds: 2));
268
+ if (!_disposed) await _connect();
269
+ }
270
+ }
271
+
272
+ Future<void> _reconnect() async {
273
+ await Future.delayed(const Duration(seconds: 1));
274
+ if (!_disposed) await _connect();
275
+ }
276
+
277
+ void _disconnect() => _socket?.close();
278
+
279
+ void dispose() {
280
+ _disposed = true;
281
+ _socket?.close();
282
+ _controller?.close();
283
+ }
284
+ }
285
+ """
286
+
287
+
288
+ # ─── Generator ────────────────────────────────────────────────────────────────
289
+
290
+ def generate(schema_url: str, output_dir: str, base_url: str = None):
291
+ """
292
+ Pull schema from running Sagnos server.
293
+ Generate all Dart files into output_dir.
294
+ """
295
+ print(f"Fetching schema from {schema_url} ...")
296
+
297
+ try:
298
+ resp = requests.get(schema_url, timeout=5)
299
+ resp.raise_for_status()
300
+ schema = resp.json()
301
+ except requests.ConnectionError:
302
+ print("Could not connect. Is the Sagnos server running?")
303
+ return
304
+ except Exception as e:
305
+ print(f"Error: {e}")
306
+ return
307
+
308
+ if base_url is None:
309
+ base_url = schema_url.replace("/sagnos/schema", "")
310
+
311
+ _write_files(schema, output_dir, base_url)
312
+
313
+
314
+ def generate_from_schema(schema: dict, output_dir: str, base_url: str = "http://127.0.0.1:8000"):
315
+ """Generate Dart files directly from a schema dict — no server needed."""
316
+ _write_files(schema, output_dir, base_url)
317
+
318
+
319
+ def _write_files(schema: dict, output_dir: str, base_url: str):
320
+ env = Environment(loader=BaseLoader(), trim_blocks=True, lstrip_blocks=True)
321
+ out = Path(output_dir)
322
+ hash = schema.get("schema_hash", "unknown")
323
+
324
+ out.mkdir(parents=True, exist_ok=True)
325
+
326
+ ctx = {
327
+ "models": schema["models"],
328
+ "endpoints": schema["endpoints"],
329
+ "streams": schema.get("streams", []),
330
+ "base_url": base_url,
331
+ "schema_hash": hash,
332
+ }
333
+
334
+ files = {
335
+ "models.dart": MODELS_TEMPLATE,
336
+ "sagnos_client.dart": CLIENT_TEMPLATE,
337
+ "sagnos_exception.dart": EXCEPTION_TEMPLATE,
338
+ "sagnos_stream.dart": STREAM_TEMPLATE,
339
+ }
340
+
341
+ for filename, tmpl_str in files.items():
342
+ content = env.from_string(tmpl_str).render(**ctx)
343
+ (out / filename).write_text(content, encoding="utf-8")
344
+ print(f" Generated: {filename}")
345
+
346
+ # Write lock file
347
+ (out / "sagnos.lock").write_text(
348
+ f"schema_hash={hash}\n"
349
+ f"sagnos_version={schema['version']}\n",
350
+ encoding="utf-8"
351
+ )
352
+
353
+ print(f"\nDone. {len(files)} files written to {output_dir}")
354
+ print(f"Schema hash: {hash}")
@@ -0,0 +1,40 @@
1
+ """
2
+ sagnos/config.py
3
+ Project config — reads/writes sagnos.json
4
+ """
5
+
6
+ import json
7
+ from pathlib import Path
8
+ from dataclasses import dataclass, asdict
9
+
10
+
11
+ CONFIG_FILE = "sagnos.json"
12
+
13
+
14
+ @dataclass
15
+ class SagnosConfig:
16
+ name: str = "my_app"
17
+ version: str = "0.1.0"
18
+ port: int = 8000
19
+ host: str = "127.0.0.1"
20
+ backend_entry: str = "backend/main.py"
21
+ ui_dir: str = "ui"
22
+ dart_output: str = "ui/lib/sagnos"
23
+
24
+ @classmethod
25
+ def load(cls, root: Path = None) -> "SagnosConfig":
26
+ root = root or Path.cwd()
27
+ config_path = root / CONFIG_FILE
28
+ if not config_path.exists():
29
+ raise FileNotFoundError(
30
+ f"No sagnos.json found in {root}.\n"
31
+ "Run `sagnos new <name>` to create a project."
32
+ )
33
+ with open(config_path) as f:
34
+ data = json.load(f)
35
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
36
+
37
+ def save(self, root: Path = None):
38
+ root = root or Path.cwd()
39
+ with open(root / CONFIG_FILE, "w") as f:
40
+ json.dump(asdict(self), f, indent=2)