vs-server 0.1.2__tar.gz → 0.1.4__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.
Files changed (33) hide show
  1. {vs_server-0.1.2/vs_server.egg-info → vs_server-0.1.4}/PKG-INFO +110 -10
  2. {vs_server-0.1.2 → vs_server-0.1.4}/README.md +109 -9
  3. {vs_server-0.1.2 → vs_server-0.1.4}/pyproject.toml +1 -1
  4. vs_server-0.1.4/vs_server/decorator/vs_controller_decorator.py +48 -0
  5. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/server/vs_fast_api_server.py +47 -8
  6. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/server/vs_server.py +0 -9
  7. {vs_server-0.1.2 → vs_server-0.1.4/vs_server.egg-info}/PKG-INFO +110 -10
  8. vs_server-0.1.2/vs_server/decorator/vs_controller_decorator.py +0 -44
  9. {vs_server-0.1.2 → vs_server-0.1.4}/LICENSE.txt +0 -0
  10. {vs_server-0.1.2 → vs_server-0.1.4}/setup.cfg +0 -0
  11. {vs_server-0.1.2 → vs_server-0.1.4}/tests/test_product_controller.py +0 -0
  12. {vs_server-0.1.2 → vs_server-0.1.4}/tests/test_server.py +0 -0
  13. {vs_server-0.1.2 → vs_server-0.1.4}/tests/test_user_controller.py +0 -0
  14. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/__init__.py +0 -0
  15. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/decorator/__init__.py +0 -0
  16. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/decorator/vs_server_decorator.py +0 -0
  17. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/decorator/vs_server_registry.py +0 -0
  18. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/decorator/vs_sse_decorator.py +0 -0
  19. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/decorator/vs_websocket_decorator.py +0 -0
  20. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/docs/__init__.py +0 -0
  21. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/docs/vs_docs_renderer.py +0 -0
  22. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/factory/__init__.py +0 -0
  23. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/factory/vs_server_factory.py +0 -0
  24. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/lifecycle/__init__.py +0 -0
  25. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/lifecycle/vs_lifecycle.py +0 -0
  26. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/schema/__init__.py +0 -0
  27. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/schema/exceptions.py +0 -0
  28. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/schema/vs_server_health.py +0 -0
  29. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server/server/__init__.py +0 -0
  30. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server.egg-info/SOURCES.txt +0 -0
  31. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server.egg-info/dependency_links.txt +0 -0
  32. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server.egg-info/requires.txt +0 -0
  33. {vs_server-0.1.2 → vs_server-0.1.4}/vs_server.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vs-server
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: HTTP server abstraction for Viveka Sutra — FastAPI wrapper with lifecycle, routing, and middleware
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -348,6 +348,52 @@ All server config lives under the `[server]` section of `config.ini`.
348
348
  | `server.ssl_keyfile_password` | — | Password for encrypted private key |
349
349
  | `api.cors_origins` | `*` | Comma-separated allowed CORS origins |
350
350
 
351
+ These keys apply to all `VsServer` implementations. Framework-specific config lives in its own section.
352
+
353
+ ### FastAPI-specific config (`[fastapi]`)
354
+
355
+ | Key | Default | Description |
356
+ |---|---|---|
357
+ | `fastapi.workers` | `1` | Number of worker processes. Requires `fastapi.app_import` to be set. |
358
+ | `fastapi.reload` | `false` | Enable hot reload (dev only). Requires `fastapi.app_import` to be set. |
359
+ | `fastapi.log_level` | `info` | Uvicorn log level (`debug`, `info`, `warning`, `error`) |
360
+ | `fastapi.app_import` | — | Import string for multi-worker or reload mode, e.g. `my_service.app:server`. Required when `workers > 1` or `reload = true`. |
361
+
362
+ **`config.ini` example for multi-worker production:**
363
+
364
+ ```ini
365
+ [server]
366
+ name = my-service
367
+ version = 1.0.0
368
+ host = 0.0.0.0
369
+ port = 8000
370
+ docs_enabled = false
371
+
372
+ [fastapi]
373
+ workers = 4
374
+ log_level = warning
375
+ app_import = my_service.app:server
376
+
377
+ [api]
378
+ cors_origins = https://app.example.com
379
+ ```
380
+
381
+ **What is `app_import`?**
382
+
383
+ When running multiple workers, uvicorn forks child processes that each need to reconstruct the app independently. They can't share the in-memory object from the parent process — they need an import path to build their own copy.
384
+
385
+ `app_import` is `module.path:variable_name` where `variable_name` is whatever you assigned `VsServerFactory.get()` to:
386
+
387
+ ```python
388
+ # my_service/app.py
389
+ server = VsServerFactory.get("fastapi", config) # → app_import = my_service.app:server
390
+ server.run()
391
+ ```
392
+
393
+ If `app_import` is not set, vs-server will attempt to auto-detect it and log it at startup. Copy the logged value into your config to make it explicit.
394
+
395
+ **Custom server implementations** follow the same pattern — add a `[my_framework]` section in `config.ini` for any framework-specific keys and read them in your `VsServer` subclass.
396
+
351
397
  **`config.ini` example:**
352
398
 
353
399
  ```ini
@@ -469,6 +515,57 @@ async def get_report():
469
515
 
470
516
  ---
471
517
 
518
+ ## Request and Response Models
519
+
520
+ All request bodies and response models must be Pydantic models — not SQLAlchemy entities or plain dicts. This is a hard requirement of FastAPI and applies to all server implementations built on vs-server.
521
+
522
+ ```python
523
+ from pydantic import BaseModel
524
+
525
+ class UserRequest(BaseModel):
526
+ name: str
527
+ email: str
528
+
529
+ class UserResponse(BaseModel):
530
+ id: str
531
+ name: str
532
+ email: str
533
+
534
+ model_config = {"from_attributes": True}
535
+ ```
536
+
537
+ The mapping between your SQLAlchemy entity and your Pydantic schema is your responsibility:
538
+
539
+ ```
540
+ Request (Pydantic) → Service → Entity (SQLAlchemy) → DB
541
+ DB → Entity (SQLAlchemy) → Service → Response (Pydantic)
542
+ ```
543
+
544
+ Never pass a SQLAlchemy entity as `response_model` — FastAPI will reject it at startup. Never return a SQLAlchemy entity directly from a route — serialize it first.
545
+
546
+ Keeping schemas separate from entities is intentional: the DB schema and API schema can evolve independently, and sensitive fields (e.g. `password`) are excluded from responses by default.
547
+
548
+ ### @model — Reduce Schema Boilerplate (requires vs-db)
549
+
550
+ If you are using `vs-db`, the `@model` decorator derives Pydantic fields directly from a SQLAlchemy entity so you don't repeat type definitions. Nullable columns become `Optional[T] = None` automatically. Fields already defined on the class are never overridden.
551
+
552
+ ```python
553
+ from pydantic import BaseModel, Field
554
+ from vs_db.decorator.vs_model_decorator import model
555
+
556
+ @model(AppUser, exclude=["password"])
557
+ class UserResponse(BaseModel):
558
+ pass # all AppUser fields injected except password
559
+
560
+ @model(AppUser, include=["email", "first_name", "last_name"])
561
+ class CreateUserRequest(BaseModel):
562
+ first_name: str = Field(min_length=1) # user-defined — decorator skips it
563
+ ```
564
+
565
+ See the `vs-db` README for full documentation on `@model`.
566
+
567
+ ---
568
+
472
569
  ## HTTP Controllers
473
570
 
474
571
  Annotate a class with `@controller` and methods with HTTP verb decorators.
@@ -520,11 +617,11 @@ All FastAPI route parameters (`response_model`, `status_code`, `tags`, `summary`
520
617
 
521
618
  | Decorator | HTTP Method |
522
619
  |---|---|
523
- | `@get(path, guards=[], **kwargs)` | GET |
524
- | `@post(path, guards=[], **kwargs)` | POST |
525
- | `@put(path, guards=[], **kwargs)` | PUT |
526
- | `@delete(path, guards=[], **kwargs)` | DELETE |
527
- | `@patch(path, guards=[], **kwargs)` | PATCH |
620
+ | `@get(path, summary, description, guards=[], **kwargs)` | GET |
621
+ | `@post(path, summary, description, guards=[], **kwargs)` | POST |
622
+ | `@put(path, summary, description, guards=[], **kwargs)` | PUT |
623
+ | `@delete(path, summary, description, guards=[], **kwargs)` | DELETE |
624
+ | `@patch(path, summary, description, guards=[], **kwargs)` | PATCH |
528
625
 
529
626
  Guards on a method are merged with controller-level guards — both run.
530
627
 
@@ -986,9 +1083,10 @@ Override template methods to customise behaviour:
986
1083
  |---|---|---|
987
1084
  | `get_host()` | Reads `server.host` | Hard-code or compute the bind address |
988
1085
  | `get_port()` | Reads `server.port` | Hard-code or compute the bind port |
989
- | `get_reload()` | Reads `server.reload` | Force hot-reload on/off |
990
- | `get_workers()` | Reads `server.workers` | Set worker count programmatically |
991
- | `get_log_level()` | Reads `server.log_level` | Set log level programmatically |
1086
+ | `get_reload()` | FastAPI only — reads `fastapi.reload` | Force hot-reload on/off |
1087
+ | `get_workers()` | FastAPI only — reads `fastapi.workers` | Set worker count programmatically |
1088
+ | `get_log_level()` | FastAPI only — reads `fastapi.log_level` | Set log level programmatically |
1089
+ | `get_app_import()` | FastAPI only — reads `fastapi.app_import` | Return import string for multi-worker mode |
992
1090
  | `get_ssl_certfile()` | Reads `server.ssl_certfile` | Load cert from a secret store |
993
1091
  | `get_ssl_keyfile()` | Reads `server.ssl_keyfile` | Load key from a secret store |
994
1092
  | `get_ssl_keyfile_password()` | Reads `server.ssl_keyfile_password` | Load password from a secret store |
@@ -1164,8 +1262,10 @@ Route decorators. Applied to methods inside a `@controller` class.
1164
1262
  | Parameter | Type | Required | Default | Description |
1165
1263
  |---|---|---|---|---|
1166
1264
  | `path` | `str` | No | `""` | Route path appended to the controller prefix |
1265
+ | `summary` | `str` | No | `None` | Short one-line description shown in Swagger |
1266
+ | `description` | `str` | No | `None` | Longer description shown in Swagger |
1167
1267
  | `guards` | `List[Callable]` | No | `[]` | Merged with controller guards |
1168
- | `**kwargs` | — | No | — | Passed to the underlying FastAPI route decorator (e.g. `response_model`, `status_code`, `summary`) |
1268
+ | `**kwargs` | — | No | — | Passed to the underlying FastAPI route decorator (e.g. `response_model`, `status_code`) |
1169
1269
 
1170
1270
  ---
1171
1271
 
@@ -129,6 +129,52 @@ All server config lives under the `[server]` section of `config.ini`.
129
129
  | `server.ssl_keyfile_password` | — | Password for encrypted private key |
130
130
  | `api.cors_origins` | `*` | Comma-separated allowed CORS origins |
131
131
 
132
+ These keys apply to all `VsServer` implementations. Framework-specific config lives in its own section.
133
+
134
+ ### FastAPI-specific config (`[fastapi]`)
135
+
136
+ | Key | Default | Description |
137
+ |---|---|---|
138
+ | `fastapi.workers` | `1` | Number of worker processes. Requires `fastapi.app_import` to be set. |
139
+ | `fastapi.reload` | `false` | Enable hot reload (dev only). Requires `fastapi.app_import` to be set. |
140
+ | `fastapi.log_level` | `info` | Uvicorn log level (`debug`, `info`, `warning`, `error`) |
141
+ | `fastapi.app_import` | — | Import string for multi-worker or reload mode, e.g. `my_service.app:server`. Required when `workers > 1` or `reload = true`. |
142
+
143
+ **`config.ini` example for multi-worker production:**
144
+
145
+ ```ini
146
+ [server]
147
+ name = my-service
148
+ version = 1.0.0
149
+ host = 0.0.0.0
150
+ port = 8000
151
+ docs_enabled = false
152
+
153
+ [fastapi]
154
+ workers = 4
155
+ log_level = warning
156
+ app_import = my_service.app:server
157
+
158
+ [api]
159
+ cors_origins = https://app.example.com
160
+ ```
161
+
162
+ **What is `app_import`?**
163
+
164
+ When running multiple workers, uvicorn forks child processes that each need to reconstruct the app independently. They can't share the in-memory object from the parent process — they need an import path to build their own copy.
165
+
166
+ `app_import` is `module.path:variable_name` where `variable_name` is whatever you assigned `VsServerFactory.get()` to:
167
+
168
+ ```python
169
+ # my_service/app.py
170
+ server = VsServerFactory.get("fastapi", config) # → app_import = my_service.app:server
171
+ server.run()
172
+ ```
173
+
174
+ If `app_import` is not set, vs-server will attempt to auto-detect it and log it at startup. Copy the logged value into your config to make it explicit.
175
+
176
+ **Custom server implementations** follow the same pattern — add a `[my_framework]` section in `config.ini` for any framework-specific keys and read them in your `VsServer` subclass.
177
+
132
178
  **`config.ini` example:**
133
179
 
134
180
  ```ini
@@ -250,6 +296,57 @@ async def get_report():
250
296
 
251
297
  ---
252
298
 
299
+ ## Request and Response Models
300
+
301
+ All request bodies and response models must be Pydantic models — not SQLAlchemy entities or plain dicts. This is a hard requirement of FastAPI and applies to all server implementations built on vs-server.
302
+
303
+ ```python
304
+ from pydantic import BaseModel
305
+
306
+ class UserRequest(BaseModel):
307
+ name: str
308
+ email: str
309
+
310
+ class UserResponse(BaseModel):
311
+ id: str
312
+ name: str
313
+ email: str
314
+
315
+ model_config = {"from_attributes": True}
316
+ ```
317
+
318
+ The mapping between your SQLAlchemy entity and your Pydantic schema is your responsibility:
319
+
320
+ ```
321
+ Request (Pydantic) → Service → Entity (SQLAlchemy) → DB
322
+ DB → Entity (SQLAlchemy) → Service → Response (Pydantic)
323
+ ```
324
+
325
+ Never pass a SQLAlchemy entity as `response_model` — FastAPI will reject it at startup. Never return a SQLAlchemy entity directly from a route — serialize it first.
326
+
327
+ Keeping schemas separate from entities is intentional: the DB schema and API schema can evolve independently, and sensitive fields (e.g. `password`) are excluded from responses by default.
328
+
329
+ ### @model — Reduce Schema Boilerplate (requires vs-db)
330
+
331
+ If you are using `vs-db`, the `@model` decorator derives Pydantic fields directly from a SQLAlchemy entity so you don't repeat type definitions. Nullable columns become `Optional[T] = None` automatically. Fields already defined on the class are never overridden.
332
+
333
+ ```python
334
+ from pydantic import BaseModel, Field
335
+ from vs_db.decorator.vs_model_decorator import model
336
+
337
+ @model(AppUser, exclude=["password"])
338
+ class UserResponse(BaseModel):
339
+ pass # all AppUser fields injected except password
340
+
341
+ @model(AppUser, include=["email", "first_name", "last_name"])
342
+ class CreateUserRequest(BaseModel):
343
+ first_name: str = Field(min_length=1) # user-defined — decorator skips it
344
+ ```
345
+
346
+ See the `vs-db` README for full documentation on `@model`.
347
+
348
+ ---
349
+
253
350
  ## HTTP Controllers
254
351
 
255
352
  Annotate a class with `@controller` and methods with HTTP verb decorators.
@@ -301,11 +398,11 @@ All FastAPI route parameters (`response_model`, `status_code`, `tags`, `summary`
301
398
 
302
399
  | Decorator | HTTP Method |
303
400
  |---|---|
304
- | `@get(path, guards=[], **kwargs)` | GET |
305
- | `@post(path, guards=[], **kwargs)` | POST |
306
- | `@put(path, guards=[], **kwargs)` | PUT |
307
- | `@delete(path, guards=[], **kwargs)` | DELETE |
308
- | `@patch(path, guards=[], **kwargs)` | PATCH |
401
+ | `@get(path, summary, description, guards=[], **kwargs)` | GET |
402
+ | `@post(path, summary, description, guards=[], **kwargs)` | POST |
403
+ | `@put(path, summary, description, guards=[], **kwargs)` | PUT |
404
+ | `@delete(path, summary, description, guards=[], **kwargs)` | DELETE |
405
+ | `@patch(path, summary, description, guards=[], **kwargs)` | PATCH |
309
406
 
310
407
  Guards on a method are merged with controller-level guards — both run.
311
408
 
@@ -767,9 +864,10 @@ Override template methods to customise behaviour:
767
864
  |---|---|---|
768
865
  | `get_host()` | Reads `server.host` | Hard-code or compute the bind address |
769
866
  | `get_port()` | Reads `server.port` | Hard-code or compute the bind port |
770
- | `get_reload()` | Reads `server.reload` | Force hot-reload on/off |
771
- | `get_workers()` | Reads `server.workers` | Set worker count programmatically |
772
- | `get_log_level()` | Reads `server.log_level` | Set log level programmatically |
867
+ | `get_reload()` | FastAPI only — reads `fastapi.reload` | Force hot-reload on/off |
868
+ | `get_workers()` | FastAPI only — reads `fastapi.workers` | Set worker count programmatically |
869
+ | `get_log_level()` | FastAPI only — reads `fastapi.log_level` | Set log level programmatically |
870
+ | `get_app_import()` | FastAPI only — reads `fastapi.app_import` | Return import string for multi-worker mode |
773
871
  | `get_ssl_certfile()` | Reads `server.ssl_certfile` | Load cert from a secret store |
774
872
  | `get_ssl_keyfile()` | Reads `server.ssl_keyfile` | Load key from a secret store |
775
873
  | `get_ssl_keyfile_password()` | Reads `server.ssl_keyfile_password` | Load password from a secret store |
@@ -945,8 +1043,10 @@ Route decorators. Applied to methods inside a `@controller` class.
945
1043
  | Parameter | Type | Required | Default | Description |
946
1044
  |---|---|---|---|---|
947
1045
  | `path` | `str` | No | `""` | Route path appended to the controller prefix |
1046
+ | `summary` | `str` | No | `None` | Short one-line description shown in Swagger |
1047
+ | `description` | `str` | No | `None` | Longer description shown in Swagger |
948
1048
  | `guards` | `List[Callable]` | No | `[]` | Merged with controller guards |
949
- | `**kwargs` | — | No | — | Passed to the underlying FastAPI route decorator (e.g. `response_model`, `status_code`, `summary`) |
1049
+ | `**kwargs` | — | No | — | Passed to the underlying FastAPI route decorator (e.g. `response_model`, `status_code`) |
950
1050
 
951
1051
  ---
952
1052
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vs-server"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "HTTP server abstraction for Viveka Sutra — FastAPI wrapper with lifecycle, routing, and middleware"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,48 @@
1
+ from typing import Callable, List, Type
2
+
3
+
4
+ def controller(path: str, description: str = "", guards: List[Callable] = None):
5
+ def decorator(cls: Type) -> Type:
6
+ cls._vs_controller = {
7
+ "path": path,
8
+ "description": description,
9
+ "guards": guards or [],
10
+ }
11
+ return cls
12
+ return decorator
13
+
14
+
15
+ def _route(method: str, path: str = "", guards: List[Callable] = None, summary: str = None, description: str = None, **kwargs):
16
+ def decorator(fn: Callable) -> Callable:
17
+ fn._vs_route = {
18
+ "method": method,
19
+ "path": path,
20
+ "guards": guards or [],
21
+ "kwargs": {
22
+ **({"summary": summary} if summary else {}),
23
+ **({"description": description} if description else {}),
24
+ **kwargs,
25
+ },
26
+ }
27
+ return fn
28
+ return decorator
29
+
30
+
31
+ def get(path: str = "", guards: List[Callable] = None, summary: str = None, description: str = None, **kwargs):
32
+ return _route("GET", path, guards=guards, summary=summary, description=description, **kwargs)
33
+
34
+
35
+ def post(path: str = "", guards: List[Callable] = None, summary: str = None, description: str = None, **kwargs):
36
+ return _route("POST", path, guards=guards, summary=summary, description=description, **kwargs)
37
+
38
+
39
+ def put(path: str = "", guards: List[Callable] = None, summary: str = None, description: str = None, **kwargs):
40
+ return _route("PUT", path, guards=guards, summary=summary, description=description, **kwargs)
41
+
42
+
43
+ def delete(path: str = "", guards: List[Callable] = None, summary: str = None, description: str = None, **kwargs):
44
+ return _route("DELETE", path, guards=guards, summary=summary, description=description, **kwargs)
45
+
46
+
47
+ def patch(path: str = "", guards: List[Callable] = None, summary: str = None, description: str = None, **kwargs):
48
+ return _route("PATCH", path, guards=guards, summary=summary, description=description, **kwargs)
@@ -167,6 +167,18 @@ class VsFastApiServer(VsServer):
167
167
  def get_app(self):
168
168
  return self._app
169
169
 
170
+ def get_reload(self) -> bool:
171
+ return self._config.get("fastapi.reload", False, bool)
172
+
173
+ def get_workers(self) -> int:
174
+ return self._config.get("fastapi.workers", 1, int)
175
+
176
+ def get_log_level(self) -> str:
177
+ return self._config.get("fastapi.log_level", "info").lower()
178
+
179
+ def get_app_import(self) -> Optional[str]:
180
+ return self._config.get("fastapi.app_import")
181
+
170
182
  def run(self) -> None:
171
183
  import uvicorn
172
184
  host = self.get_host()
@@ -174,14 +186,19 @@ class VsFastApiServer(VsServer):
174
186
  reload = self.get_reload()
175
187
  workers = self.get_workers()
176
188
  log_level = self.get_log_level()
189
+ app_import = self.get_app_import()
177
190
  ssl_certfile = self.get_ssl_certfile()
178
191
  ssl_keyfile = self.get_ssl_keyfile()
179
192
  ssl_keyfile_password = self.get_ssl_keyfile_password()
180
193
 
181
- self._print_banner(host, port, reload, tls=bool(ssl_certfile and ssl_keyfile))
194
+ self._print_banner(host, port, reload, workers, tls=bool(ssl_certfile and ssl_keyfile))
195
+
196
+ use_import = workers > 1 or reload
197
+ if use_import and not app_import:
198
+ app_import = self._detect_app_import()
182
199
 
183
200
  uvicorn_config = {
184
- "app": self._app,
201
+ "app": app_import if use_import and app_import else self._app,
185
202
  "host": host,
186
203
  "port": port,
187
204
  "log_level": log_level,
@@ -189,7 +206,13 @@ class VsFastApiServer(VsServer):
189
206
  if reload:
190
207
  uvicorn_config["reload"] = True
191
208
  elif workers > 1:
192
- uvicorn_config["workers"] = workers
209
+ if app_import:
210
+ uvicorn_config["workers"] = workers
211
+ else:
212
+ self._logger.warning(
213
+ "fastapi.workers > 1 requires fastapi.app_import to be set in config.ini. "
214
+ "Falling back to 1 worker."
215
+ )
193
216
 
194
217
  if ssl_certfile and ssl_keyfile:
195
218
  uvicorn_config["ssl_certfile"] = ssl_certfile
@@ -199,6 +222,21 @@ class VsFastApiServer(VsServer):
199
222
 
200
223
  uvicorn.run(**uvicorn_config)
201
224
 
225
+ def _detect_app_import(self) -> Optional[str]:
226
+ try:
227
+ import __main__
228
+ module = __main__.__spec__.name if hasattr(__main__, "__spec__") and __main__.__spec__ else None
229
+ if not module:
230
+ return None
231
+ for var_name, val in vars(__main__).items():
232
+ if val is self._app:
233
+ detected = f"{module}:{var_name}"
234
+ self._logger.info(f"Detected app_import = {detected} (set fastapi.app_import in config.ini to make this explicit)")
235
+ return detected
236
+ except Exception:
237
+ pass
238
+ return None
239
+
202
240
  # ------------------------------------------------------------------
203
241
  # Internal
204
242
  # ------------------------------------------------------------------
@@ -317,16 +355,17 @@ class VsFastApiServer(VsServer):
317
355
  )(fn)
318
356
  app.include_router(router)
319
357
 
320
- def _print_banner(self, host: str, port: int, reload: bool, tls: bool = False) -> None:
358
+ def _print_banner(self, host: str, port: int, reload: bool, workers: int = 1, tls: bool = False) -> None:
321
359
  mode = "Development (reload)" if reload else "Production"
322
360
  scheme = "https" if tls else "http"
323
361
  self._logger.info("=" * 50)
324
362
  self._logger.info(f" {self.name} v{self.version}")
325
363
  self._logger.info("=" * 50)
326
- self._logger.info(f" Host : {host}")
327
- self._logger.info(f" Port : {port}")
328
- self._logger.info(f" Mode : {mode}")
329
- self._logger.info(f" TLS : {'enabled' if tls else 'disabled'}")
364
+ self._logger.info(f" Host : {host}")
365
+ self._logger.info(f" Port : {port}")
366
+ self._logger.info(f" Mode : {mode}")
367
+ self._logger.info(f" Workers : {workers}")
368
+ self._logger.info(f" TLS : {'enabled' if tls else 'disabled'}")
330
369
  if self.is_docs_enabled() and self.get_docs_url():
331
370
  self._logger.info(f" Docs : {scheme}://{host}:{port}{self.get_docs_url()}")
332
371
  self._logger.info(f" WS/SSE : {scheme}://{host}:{port}/vs-docs")
@@ -48,15 +48,6 @@ class VsServer(ABC):
48
48
  def get_port(self) -> int:
49
49
  return self._config.get("server.port", 8000, int)
50
50
 
51
- def get_reload(self) -> bool:
52
- return self._config.get("server.reload", False, bool)
53
-
54
- def get_workers(self) -> int:
55
- return self._config.get("server.workers", 1, int)
56
-
57
- def get_log_level(self) -> str:
58
- return self._config.get("server.log_level", "info").lower()
59
-
60
51
  def get_ssl_certfile(self) -> Optional[str]:
61
52
  return self._config.get("server.ssl_certfile")
62
53
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vs-server
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: HTTP server abstraction for Viveka Sutra — FastAPI wrapper with lifecycle, routing, and middleware
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -348,6 +348,52 @@ All server config lives under the `[server]` section of `config.ini`.
348
348
  | `server.ssl_keyfile_password` | — | Password for encrypted private key |
349
349
  | `api.cors_origins` | `*` | Comma-separated allowed CORS origins |
350
350
 
351
+ These keys apply to all `VsServer` implementations. Framework-specific config lives in its own section.
352
+
353
+ ### FastAPI-specific config (`[fastapi]`)
354
+
355
+ | Key | Default | Description |
356
+ |---|---|---|
357
+ | `fastapi.workers` | `1` | Number of worker processes. Requires `fastapi.app_import` to be set. |
358
+ | `fastapi.reload` | `false` | Enable hot reload (dev only). Requires `fastapi.app_import` to be set. |
359
+ | `fastapi.log_level` | `info` | Uvicorn log level (`debug`, `info`, `warning`, `error`) |
360
+ | `fastapi.app_import` | — | Import string for multi-worker or reload mode, e.g. `my_service.app:server`. Required when `workers > 1` or `reload = true`. |
361
+
362
+ **`config.ini` example for multi-worker production:**
363
+
364
+ ```ini
365
+ [server]
366
+ name = my-service
367
+ version = 1.0.0
368
+ host = 0.0.0.0
369
+ port = 8000
370
+ docs_enabled = false
371
+
372
+ [fastapi]
373
+ workers = 4
374
+ log_level = warning
375
+ app_import = my_service.app:server
376
+
377
+ [api]
378
+ cors_origins = https://app.example.com
379
+ ```
380
+
381
+ **What is `app_import`?**
382
+
383
+ When running multiple workers, uvicorn forks child processes that each need to reconstruct the app independently. They can't share the in-memory object from the parent process — they need an import path to build their own copy.
384
+
385
+ `app_import` is `module.path:variable_name` where `variable_name` is whatever you assigned `VsServerFactory.get()` to:
386
+
387
+ ```python
388
+ # my_service/app.py
389
+ server = VsServerFactory.get("fastapi", config) # → app_import = my_service.app:server
390
+ server.run()
391
+ ```
392
+
393
+ If `app_import` is not set, vs-server will attempt to auto-detect it and log it at startup. Copy the logged value into your config to make it explicit.
394
+
395
+ **Custom server implementations** follow the same pattern — add a `[my_framework]` section in `config.ini` for any framework-specific keys and read them in your `VsServer` subclass.
396
+
351
397
  **`config.ini` example:**
352
398
 
353
399
  ```ini
@@ -469,6 +515,57 @@ async def get_report():
469
515
 
470
516
  ---
471
517
 
518
+ ## Request and Response Models
519
+
520
+ All request bodies and response models must be Pydantic models — not SQLAlchemy entities or plain dicts. This is a hard requirement of FastAPI and applies to all server implementations built on vs-server.
521
+
522
+ ```python
523
+ from pydantic import BaseModel
524
+
525
+ class UserRequest(BaseModel):
526
+ name: str
527
+ email: str
528
+
529
+ class UserResponse(BaseModel):
530
+ id: str
531
+ name: str
532
+ email: str
533
+
534
+ model_config = {"from_attributes": True}
535
+ ```
536
+
537
+ The mapping between your SQLAlchemy entity and your Pydantic schema is your responsibility:
538
+
539
+ ```
540
+ Request (Pydantic) → Service → Entity (SQLAlchemy) → DB
541
+ DB → Entity (SQLAlchemy) → Service → Response (Pydantic)
542
+ ```
543
+
544
+ Never pass a SQLAlchemy entity as `response_model` — FastAPI will reject it at startup. Never return a SQLAlchemy entity directly from a route — serialize it first.
545
+
546
+ Keeping schemas separate from entities is intentional: the DB schema and API schema can evolve independently, and sensitive fields (e.g. `password`) are excluded from responses by default.
547
+
548
+ ### @model — Reduce Schema Boilerplate (requires vs-db)
549
+
550
+ If you are using `vs-db`, the `@model` decorator derives Pydantic fields directly from a SQLAlchemy entity so you don't repeat type definitions. Nullable columns become `Optional[T] = None` automatically. Fields already defined on the class are never overridden.
551
+
552
+ ```python
553
+ from pydantic import BaseModel, Field
554
+ from vs_db.decorator.vs_model_decorator import model
555
+
556
+ @model(AppUser, exclude=["password"])
557
+ class UserResponse(BaseModel):
558
+ pass # all AppUser fields injected except password
559
+
560
+ @model(AppUser, include=["email", "first_name", "last_name"])
561
+ class CreateUserRequest(BaseModel):
562
+ first_name: str = Field(min_length=1) # user-defined — decorator skips it
563
+ ```
564
+
565
+ See the `vs-db` README for full documentation on `@model`.
566
+
567
+ ---
568
+
472
569
  ## HTTP Controllers
473
570
 
474
571
  Annotate a class with `@controller` and methods with HTTP verb decorators.
@@ -520,11 +617,11 @@ All FastAPI route parameters (`response_model`, `status_code`, `tags`, `summary`
520
617
 
521
618
  | Decorator | HTTP Method |
522
619
  |---|---|
523
- | `@get(path, guards=[], **kwargs)` | GET |
524
- | `@post(path, guards=[], **kwargs)` | POST |
525
- | `@put(path, guards=[], **kwargs)` | PUT |
526
- | `@delete(path, guards=[], **kwargs)` | DELETE |
527
- | `@patch(path, guards=[], **kwargs)` | PATCH |
620
+ | `@get(path, summary, description, guards=[], **kwargs)` | GET |
621
+ | `@post(path, summary, description, guards=[], **kwargs)` | POST |
622
+ | `@put(path, summary, description, guards=[], **kwargs)` | PUT |
623
+ | `@delete(path, summary, description, guards=[], **kwargs)` | DELETE |
624
+ | `@patch(path, summary, description, guards=[], **kwargs)` | PATCH |
528
625
 
529
626
  Guards on a method are merged with controller-level guards — both run.
530
627
 
@@ -986,9 +1083,10 @@ Override template methods to customise behaviour:
986
1083
  |---|---|---|
987
1084
  | `get_host()` | Reads `server.host` | Hard-code or compute the bind address |
988
1085
  | `get_port()` | Reads `server.port` | Hard-code or compute the bind port |
989
- | `get_reload()` | Reads `server.reload` | Force hot-reload on/off |
990
- | `get_workers()` | Reads `server.workers` | Set worker count programmatically |
991
- | `get_log_level()` | Reads `server.log_level` | Set log level programmatically |
1086
+ | `get_reload()` | FastAPI only — reads `fastapi.reload` | Force hot-reload on/off |
1087
+ | `get_workers()` | FastAPI only — reads `fastapi.workers` | Set worker count programmatically |
1088
+ | `get_log_level()` | FastAPI only — reads `fastapi.log_level` | Set log level programmatically |
1089
+ | `get_app_import()` | FastAPI only — reads `fastapi.app_import` | Return import string for multi-worker mode |
992
1090
  | `get_ssl_certfile()` | Reads `server.ssl_certfile` | Load cert from a secret store |
993
1091
  | `get_ssl_keyfile()` | Reads `server.ssl_keyfile` | Load key from a secret store |
994
1092
  | `get_ssl_keyfile_password()` | Reads `server.ssl_keyfile_password` | Load password from a secret store |
@@ -1164,8 +1262,10 @@ Route decorators. Applied to methods inside a `@controller` class.
1164
1262
  | Parameter | Type | Required | Default | Description |
1165
1263
  |---|---|---|---|---|
1166
1264
  | `path` | `str` | No | `""` | Route path appended to the controller prefix |
1265
+ | `summary` | `str` | No | `None` | Short one-line description shown in Swagger |
1266
+ | `description` | `str` | No | `None` | Longer description shown in Swagger |
1167
1267
  | `guards` | `List[Callable]` | No | `[]` | Merged with controller guards |
1168
- | `**kwargs` | — | No | — | Passed to the underlying FastAPI route decorator (e.g. `response_model`, `status_code`, `summary`) |
1268
+ | `**kwargs` | — | No | — | Passed to the underlying FastAPI route decorator (e.g. `response_model`, `status_code`) |
1169
1269
 
1170
1270
  ---
1171
1271
 
@@ -1,44 +0,0 @@
1
- from typing import Callable, List, Type
2
-
3
-
4
- def controller(path: str, description: str = "", guards: List[Callable] = None):
5
- def decorator(cls: Type) -> Type:
6
- cls._vs_controller = {
7
- "path": path,
8
- "description": description,
9
- "guards": guards or [],
10
- }
11
- return cls
12
- return decorator
13
-
14
-
15
- def _route(method: str, path: str = "", guards: List[Callable] = None, **kwargs):
16
- def decorator(fn: Callable) -> Callable:
17
- fn._vs_route = {
18
- "method": method,
19
- "path": path,
20
- "guards": guards or [],
21
- "kwargs": kwargs,
22
- }
23
- return fn
24
- return decorator
25
-
26
-
27
- def get(path: str = "", guards: List[Callable] = None, **kwargs):
28
- return _route("GET", path, guards=guards, **kwargs)
29
-
30
-
31
- def post(path: str = "", guards: List[Callable] = None, **kwargs):
32
- return _route("POST", path, guards=guards, **kwargs)
33
-
34
-
35
- def put(path: str = "", guards: List[Callable] = None, **kwargs):
36
- return _route("PUT", path, guards=guards, **kwargs)
37
-
38
-
39
- def delete(path: str = "", guards: List[Callable] = None, **kwargs):
40
- return _route("DELETE", path, guards=guards, **kwargs)
41
-
42
-
43
- def patch(path: str = "", guards: List[Callable] = None, **kwargs):
44
- return _route("PATCH", path, guards=guards, **kwargs)
File without changes
File without changes