coloco 0.3.4__tar.gz → 0.3.6__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 (49) hide show
  1. {coloco-0.3.4 → coloco-0.3.6}/PKG-INFO +25 -18
  2. {coloco-0.3.4 → coloco-0.3.6}/README.md +16 -9
  3. {coloco-0.3.4 → coloco-0.3.6}/pyproject.toml +13 -9
  4. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/__init__.py +6 -2
  5. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/__main__.py +6 -6
  6. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/api.py +29 -19
  7. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/app.py +49 -20
  8. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/cli/api.py +8 -8
  9. coloco-0.3.6/src/coloco/cli/build.py +66 -0
  10. coloco-0.3.6/src/coloco/cli/db.py +144 -0
  11. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/cli/dev.py +11 -7
  12. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/cli/node.py +6 -6
  13. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/cli/serve.py +5 -5
  14. coloco-0.3.6/src/coloco/db.py +143 -0
  15. coloco-0.3.6/src/coloco/dependencies.py +6 -0
  16. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/static.py +3 -3
  17. coloco-0.3.4/src/coloco/cli/build.py +0 -22
  18. coloco-0.3.4/src/coloco/cli/db.py +0 -100
  19. coloco-0.3.4/src/coloco/db.py +0 -39
  20. {coloco-0.3.4 → coloco-0.3.6}/LICENSE +0 -0
  21. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/cli/createapp.py +0 -0
  22. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/cli/package.py +0 -0
  23. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/codegen.py +0 -0
  24. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/config.py +0 -0
  25. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/exceptions.py +0 -0
  26. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/lifespan.py +0 -0
  27. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/docker/.dockerignore-tpl +0 -0
  28. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/docker/Dockerfile-tpl +0 -0
  29. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/docker/docker-compose.yml-tpl +0 -0
  30. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+app/404.svelte-tpl +0 -0
  31. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+app/app.css-tpl +0 -0
  32. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+app/app.svelte-tpl +0 -0
  33. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+app/index.html-tpl +0 -0
  34. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+app/index.svelte-tpl +0 -0
  35. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+app/main.ts-tpl +0 -0
  36. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+node/openapi-ts.config.ts-tpl +0 -0
  37. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+node/package.json-tpl +0 -0
  38. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+node/svelte.config.js-tpl +0 -0
  39. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+node/tsconfig.json-tpl +0 -0
  40. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+node/vite-env.d.ts-tpl +0 -0
  41. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/+node/vite.config.ts-tpl +0 -0
  42. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/.gitignore-tpl +0 -0
  43. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/README.md-tpl +0 -0
  44. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/example/api.py-tpl +0 -0
  45. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/example/index.svelte-tpl +0 -0
  46. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/main.py-tpl +0 -0
  47. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/pyproject.toml-tpl +0 -0
  48. {coloco-0.3.4 → coloco-0.3.6}/src/coloco/templates/standard/tsconfig.json-tpl +0 -0
  49. {coloco-0.3.4 → coloco-0.3.6}/tests/__init__.py +0 -0
@@ -1,17 +1,17 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: coloco
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: A kit for creating FastAPI + Svelte applications
5
5
  Author-Email: Channel Cat <channelcat@gmail.com>
6
6
  License: MIT
7
- Requires-Python: >=3.11
8
- Requires-Dist: aerich[toml]==0.8.1
9
- Requires-Dist: fastapi==0.115.0
10
- Requires-Dist: PyJWT==2.10.1
11
- Requires-Dist: tortoise-orm[asyncpg]==0.24.0
12
- Requires-Dist: type-less>=0.1.8
13
- Requires-Dist: typer==0.15.2
14
- Requires-Dist: uvicorn==0.31.0
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: cyclopts>=3.18.0
9
+ Requires-Dist: fastapi>=0.115.0
10
+ Requires-Dist: PyJWT>=2.10.1
11
+ Requires-Dist: tortoise-orm[asyncpg]>=0.24.0
12
+ Requires-Dist: tortoise-pathway==0.1.6
13
+ Requires-Dist: type-less>=0.1.16
14
+ Requires-Dist: uvicorn>=0.31.0
15
15
  Description-Content-Type: text/markdown
16
16
 
17
17
  # Coloco
@@ -56,8 +56,8 @@ Serves the page `myapp.com/hello`, which calls `myapp.com/hello/test?name=Coloco
56
56
  # Running in Production
57
57
 
58
58
  - `coloco build`
59
-
60
- - `coloco serve`
59
+ - Artifacts will be saved to `dist`
60
+ - From dist, run `coloco serve`
61
61
 
62
62
  # Opinions
63
63
 
@@ -69,21 +69,28 @@ This framework is opinionated and combines the following excellent tools:
69
69
  - svelte5-router (file-based routing)
70
70
  - tortoise-orm (optional)
71
71
 
72
- # Plans
72
+ # TBD Required features for launch
73
73
 
74
74
  - Deploy tools
75
- - ORM Support
76
- - SQLite/Postgres support
75
+ - Swap migrations to `tortoise-pathway` once cross-app migrations are supported
76
+ - Config with secrets encryption
77
77
  - Package/share modules with git
78
+ - Docs
79
+
80
+ # Features in development
81
+
82
+ - CRUD
83
+ - Caching
84
+ - User module
85
+ - Dev tools module
86
+ - Admin panel module
78
87
 
79
88
  # Dreams
80
89
 
81
90
  - Move `node_modules` into `+node`
82
- - Somehow clean up litter from `uv`
83
91
 
84
- # TODO
92
+ # Other TODO
85
93
 
86
- - Swap to `cyclopts` for CLI
87
94
  - Swap to `copier` for template creation
88
95
  - `Textual` CLI app / installer / dev tools?
89
- - Common dev `admin` module
96
+ - Resolve duplicate types in openapi spec (due to model serializers)
@@ -40,8 +40,8 @@ Serves the page `myapp.com/hello`, which calls `myapp.com/hello/test?name=Coloco
40
40
  # Running in Production
41
41
 
42
42
  - `coloco build`
43
-
44
- - `coloco serve`
43
+ - Artifacts will be saved to `dist`
44
+ - From dist, run `coloco serve`
45
45
 
46
46
  # Opinions
47
47
 
@@ -53,21 +53,28 @@ This framework is opinionated and combines the following excellent tools:
53
53
  - svelte5-router (file-based routing)
54
54
  - tortoise-orm (optional)
55
55
 
56
- # Plans
56
+ # TBD Required features for launch
57
57
 
58
58
  - Deploy tools
59
- - ORM Support
60
- - SQLite/Postgres support
59
+ - Swap migrations to `tortoise-pathway` once cross-app migrations are supported
60
+ - Config with secrets encryption
61
61
  - Package/share modules with git
62
+ - Docs
63
+
64
+ # Features in development
65
+
66
+ - CRUD
67
+ - Caching
68
+ - User module
69
+ - Dev tools module
70
+ - Admin panel module
62
71
 
63
72
  # Dreams
64
73
 
65
74
  - Move `node_modules` into `+node`
66
- - Somehow clean up litter from `uv`
67
75
 
68
- # TODO
76
+ # Other TODO
69
77
 
70
- - Swap to `cyclopts` for CLI
71
78
  - Swap to `copier` for template creation
72
79
  - `Textual` CLI app / installer / dev tools?
73
- - Common dev `admin` module
80
+ - Resolve duplicate types in openapi spec (due to model serializers)
@@ -1,20 +1,20 @@
1
1
  [project]
2
2
  name = "coloco"
3
- version = "0.3.4"
3
+ version = "0.3.6"
4
4
  description = "A kit for creating FastAPI + Svelte applications"
5
5
  authors = [
6
6
  { name = "Channel Cat", email = "channelcat@gmail.com" },
7
7
  ]
8
8
  dependencies = [
9
- "aerich[toml]==0.8.1",
10
- "fastapi==0.115.0",
11
- "PyJWT==2.10.1",
12
- "tortoise-orm[asyncpg]==0.24.0",
13
- "type-less>=0.1.8",
14
- "typer==0.15.2",
15
- "uvicorn==0.31.0",
9
+ "cyclopts>=3.18.0",
10
+ "fastapi>=0.115.0",
11
+ "PyJWT>=2.10.1",
12
+ "tortoise-orm[asyncpg]>=0.24.0",
13
+ "tortoise-pathway==0.1.6",
14
+ "type-less>=0.1.16",
15
+ "uvicorn>=0.31.0",
16
16
  ]
17
- requires-python = ">=3.11"
17
+ requires-python = ">=3.12"
18
18
  readme = "README.md"
19
19
 
20
20
  [project.license]
@@ -31,3 +31,7 @@ build-backend = "pdm.backend"
31
31
 
32
32
  [tool.pdm]
33
33
  distribution = true
34
+
35
+ [tool.uv.sources.tortoise-pathway]
36
+ git = "https://github.com/channelcat/tortoise-pathway"
37
+ rev = "multi-app-support"
@@ -1,12 +1,16 @@
1
- from .app import create_app
1
+ from .api import api
2
+ from .app import ColocoApp, create_app, get_current_app
2
3
  from .codegen import generate_openapi_schema, generate_openapi_code
3
4
  from .exceptions import UserError
4
- from .api import api
5
+ from . import dependencies
5
6
 
6
7
  __all__ = [
7
8
  "api",
9
+ "ColocoApp",
8
10
  "create_app",
11
+ "dependencies",
9
12
  "generate_openapi_schema",
10
13
  "generate_openapi_code",
14
+ "get_current_app",
11
15
  "UserError",
12
16
  ]
@@ -1,16 +1,16 @@
1
- import typer
2
1
  from .cli.dev import dev
3
2
  from .cli.api import app as api_app
4
3
  from .cli.node import app as node_app
5
4
  from .cli.createapp import createapp
6
5
  from .cli.build import build
7
6
  from .cli.serve import serve
7
+ import cyclopts
8
8
 
9
- app = typer.Typer()
9
+ app = cyclopts.App()
10
10
 
11
- app.command()(dev)
12
- app.add_typer(node_app, name="node")
13
- app.add_typer(api_app, name="api")
11
+ app.command(dev, name="dev")
12
+ app.command(node_app, name="node")
13
+ app.command(api_app, name="api")
14
14
  app.command()(build)
15
15
  app.command()(createapp)
16
16
  app.command()(serve)
@@ -19,7 +19,7 @@ app.command()(serve)
19
19
  try:
20
20
  from .cli.db import app as db_app
21
21
 
22
- app.add_typer(db_app, name="db")
22
+ app.command(db_app, name="db")
23
23
  except ImportError:
24
24
  pass
25
25
 
@@ -4,20 +4,36 @@ from .codegen import (
4
4
  generate_openapi_schema,
5
5
  )
6
6
  from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
7
8
  from .exceptions import bind_exceptions
8
9
  from fastapi import APIRouter, FastAPI
9
10
  from fastapi.middleware.cors import CORSMiddleware
10
11
  from .lifespan import execute_lifespan, register_lifespan
11
12
  import logging
12
13
  from os import environ
13
- from type_less import fill_type_hints
14
14
  from threading import Thread
15
+ from type_less import fill_type_hints
16
+ from typing import Callable, TypeVar
15
17
 
16
18
 
17
19
  logging.basicConfig(level=logging.INFO)
18
20
 
19
21
 
22
+ T = TypeVar("T")
23
+
24
+
25
+ @dataclass
26
+ class ColocoRoute:
27
+ args: tuple
28
+ kwargs: dict
29
+ func: Callable
30
+ method: str
31
+ module_name: str
32
+
33
+
20
34
  def _generate_openapi_thread(app: FastAPI):
35
+ # TODO: diff check the schema json
36
+ # TODO: diff check not working
21
37
  logging.info("Generating OpenAPI schema in thread...")
22
38
  generate_openapi_schema(app)
23
39
  generate_openapi_code(host=f"http://localhost:5172", diff_files=True)
@@ -33,16 +49,17 @@ async def generate_openapi(app: FastAPI):
33
49
 
34
50
 
35
51
  def create_api(is_dev: bool = False):
52
+ kwargs = {
53
+ "lifespan": execute_lifespan,
54
+ }
36
55
  if not is_dev:
37
56
  kwargs = {
57
+ **kwargs,
38
58
  "openapi_url": None,
39
59
  "docs_url": None,
40
60
  "redoc_url": None,
41
61
  }
42
62
  else:
43
- kwargs = {
44
- "lifespan": execute_lifespan,
45
- }
46
63
  register_lifespan(generate_openapi)
47
64
 
48
65
  api = FastAPI(
@@ -60,7 +77,6 @@ def create_api(is_dev: bool = False):
60
77
  allow_headers=["*"],
61
78
  )
62
79
 
63
- # bind_static(api)
64
80
  bind_exceptions(api, debug=is_dev)
65
81
 
66
82
  return api
@@ -68,7 +84,7 @@ def create_api(is_dev: bool = False):
68
84
 
69
85
  # ========================= Global routing =========================
70
86
 
71
- global_router = APIRouter()
87
+ global_routes: list[ColocoRoute] = []
72
88
 
73
89
 
74
90
  def api(func):
@@ -77,8 +93,6 @@ def api(func):
77
93
 
78
94
 
79
95
  def _add_global_route(args, kwargs, func, method: str):
80
- fill_type_hints(func, use_literals=True)
81
-
82
96
  module_name = func.__module__.lstrip("src.app")
83
97
 
84
98
  # Prepend module name to path
@@ -87,7 +101,10 @@ def _add_global_route(args, kwargs, func, method: str):
87
101
  # TODO: Make this configurable
88
102
  "/api/"
89
103
  # TODO: Make this read project configuration, probably need to add routes after running
90
- + module_name.rsplit(".", 1)[0].replace(".", "/")
104
+ + module_name.rsplit(".", 1)[0]
105
+ .replace(".", "/")
106
+ .replace(".-", ".") # Strip - from folders (for dev only)
107
+ .lstrip("-")
91
108
  + ("" if path.startswith("/") else "/")
92
109
  + path
93
110
  )
@@ -96,18 +113,11 @@ def _add_global_route(args, kwargs, func, method: str):
96
113
  else:
97
114
  kwargs["path"] = path
98
115
 
99
- return global_router.api_route(
100
- *args,
101
- **{
102
- **kwargs,
103
- "summary": (kwargs.get("summary", "") + f" ({module_name})").strip(),
104
- "methods": [method],
105
- },
106
- )(func)
116
+ return global_routes.append(ColocoRoute(args, kwargs, func, method, module_name))
107
117
 
108
118
 
109
- def _make_route_decorator(method: str):
110
- def route_wrapper(*args, **kwargs):
119
+ def _make_route_decorator(method: str) -> Callable[..., Callable[[T], T]]:
120
+ def route_wrapper(*args, **kwargs) -> Callable[[T], T]:
111
121
  def handler_wrapper(func):
112
122
  return _add_global_route(args, kwargs, func, method)
113
123
 
@@ -1,13 +1,17 @@
1
- from .api import create_api, global_router
1
+ from .api import create_api, global_routes
2
2
  from dataclasses import dataclass
3
- from .db import get_orm_config, lifecycle_connect_database
4
- from fastapi import FastAPI
3
+ from .db import (
4
+ get_orm_config,
5
+ inject_model_serializers,
6
+ register_db_lifecycle,
7
+ )
8
+ from fastapi import APIRouter, FastAPI
5
9
  from importlib import import_module
6
- from .lifespan import register_lifespan
7
10
  import os
8
11
  from rich import print
9
12
  from .static import bind_static
10
13
  import traceback
14
+ from type_less import fill_type_hints
11
15
  from typing import Literal
12
16
 
13
17
 
@@ -17,10 +21,10 @@ class ColocoApp:
17
21
  name: str
18
22
  database_url: str = None
19
23
  orm_config: dict = None
20
- migrations_dir: str = "./+migrations"
24
+ migrations_dir: str = "+migrations"
21
25
 
22
26
 
23
- def discover_files(directory, name):
27
+ def discover_files(directory, name, is_dev=False):
24
28
  api_files = []
25
29
  try:
26
30
  with os.scandir(directory) as entries:
@@ -29,12 +33,12 @@ def discover_files(directory, name):
29
33
  # Skip directories starting with "+" and "node_modules"
30
34
  if (
31
35
  not entry.name.startswith("+")
32
- and not entry.name.startswith("-")
36
+ and (not entry.name.startswith("-") or is_dev)
33
37
  and not entry.name.startswith(".")
34
38
  and not entry.name == "node_modules"
35
39
  and not entry.name == "coloco"
36
40
  ):
37
- api_files.extend(discover_files(entry.path, name))
41
+ api_files.extend(discover_files(entry.path, name, is_dev))
38
42
  elif entry.is_file() and entry.name == name:
39
43
  api_files.append(entry.path)
40
44
  except (PermissionError, FileNotFoundError) as e:
@@ -53,8 +57,10 @@ def create_app(name: str, database_url: str = None) -> ColocoApp:
53
57
  mode: Literal["dev", "prod"] = os.environ.get("COLOCO_MODE", "dev")
54
58
  api = create_api(is_dev=mode == "dev")
55
59
 
60
+ src_path = "src"
61
+
56
62
  # Discover all api.py files from root, excluding node_modules and +app
57
- api_files = discover_files(".", name="api.py")
63
+ api_files = discover_files(src_path, name="api.py", is_dev=mode == "dev")
58
64
  for api_file in api_files:
59
65
  # convert python file path to module path
60
66
  module_name = api_file.replace("./", "").replace(".py", "").replace("/", ".")
@@ -65,24 +71,47 @@ def create_app(name: str, database_url: str = None) -> ColocoApp:
65
71
  print(traceback.format_exc())
66
72
  continue
67
73
 
68
- api.include_router(global_router)
69
-
70
- # Production mode serves dist
71
- if mode == "prod":
72
- bind_static(api)
74
+ # Inject type hints
75
+ for route in global_routes:
76
+ fill_type_hints(route.func, use_literals=True)
73
77
 
74
78
  # Setup Database
75
- if database_url:
79
+ # We need this first to grab the models for route type hints
80
+ has_database = bool(database_url)
81
+ if has_database:
76
82
  orm_config = get_orm_config(
77
- database_url, model_files=discover_files(".", name="models.py")
83
+ database_url,
84
+ model_files=discover_files(src_path, name="models.py", is_dev=mode == "dev"),
78
85
  )
79
- register_lifespan(lifecycle_connect_database)
86
+ inject_model_serializers(orm_config, global_routes)
80
87
  else:
81
88
  orm_config = None
82
89
 
83
- CURRENT_APP = ColocoApp(
84
- api=api, name=name, database_url=database_url, orm_config=orm_config
85
- )
90
+ router = APIRouter()
91
+ for route in global_routes:
92
+ router.api_route(
93
+ *route.args,
94
+ **{
95
+ **route.kwargs,
96
+ **{
97
+ "summary": (
98
+ route.kwargs.get("summary", "") + f" ({route.module_name})"
99
+ ).strip(),
100
+ "methods": [route.method],
101
+ },
102
+ },
103
+ )(route.func)
104
+ api.include_router(router)
105
+
106
+ # Production mode serves dist
107
+ if mode == "prod":
108
+ bind_static(api)
109
+
110
+ CURRENT_APP = ColocoApp(api=api, name=name, database_url=database_url, orm_config=orm_config)
111
+
112
+ if database_url:
113
+ register_db_lifecycle(CURRENT_APP)
114
+
86
115
  return CURRENT_APP
87
116
 
88
117
 
@@ -1,15 +1,15 @@
1
1
  from ..app import ColocoApp
2
2
  from ..codegen import generate_openapi_schema, generate_openapi_code
3
+ import cyclopts
3
4
  from importlib import import_module
4
5
  import os
5
6
  from rich import print
6
7
  import sys
7
- import typer
8
8
  from typing import Literal
9
9
  import uvicorn
10
10
 
11
11
 
12
- app = typer.Typer()
12
+ app = cyclopts.App()
13
13
 
14
14
 
15
15
  def _verify_app(app: str = "src.main.app") -> ColocoApp:
@@ -17,7 +17,7 @@ def _verify_app(app: str = "src.main.app") -> ColocoApp:
17
17
  print(
18
18
  "[red]App should be the name of a variable in a python file, example: main.py -> api = main.api[/red]"
19
19
  )
20
- raise typer.Abort()
20
+ raise SystemExit(1)
21
21
 
22
22
  module_name, var_name = app.rsplit(".", 1)
23
23
  try:
@@ -26,17 +26,17 @@ def _verify_app(app: str = "src.main.app") -> ColocoApp:
26
26
  module = import_module(module_name)
27
27
  except ModuleNotFoundError:
28
28
  print(f"[red]Module or python file {module_name} not found[/red]")
29
- raise typer.Abort()
29
+ raise SystemExit(1)
30
30
 
31
31
  if not hasattr(module, var_name):
32
32
  print(f"[red]Variable {var_name} not found in module {module_name}[/red]")
33
- raise typer.Abort()
33
+ raise SystemExit(1)
34
34
 
35
35
  var = getattr(module, var_name)
36
36
 
37
37
  if not isinstance(var, ColocoApp):
38
38
  print(f"[red]{var_name} is not a ColocoApp. Please use create_app[/red]")
39
- raise typer.Abort()
39
+ raise SystemExit(1)
40
40
 
41
41
  return var
42
42
 
@@ -50,12 +50,12 @@ def _verify_is_packaged():
50
50
  print(
51
51
  f"[red]Dist dir {dist_dir} does not exist. Run [green]coloco build[/green] to package the app.[/red]"
52
52
  )
53
- raise typer.Abort()
53
+ raise SystemExit(1)
54
54
  if not os.path.exists(app_dir):
55
55
  print(
56
56
  f"[red]App is missing from package directory {app_dir}. Run [green]coloco build[/green] to package the app.[/red]"
57
57
  )
58
- raise typer.Abort()
58
+ raise SystemExit(1)
59
59
 
60
60
 
61
61
  def _serve(
@@ -0,0 +1,66 @@
1
+ from .api import _verify_app, codegen
2
+ from ..config import get_coloco_config
3
+ from .node import build as build_node
4
+ import os
5
+ from pathlib import Path
6
+ from rich import print
7
+ import shutil
8
+
9
+
10
+ def build(
11
+ app: str | None = None,
12
+ ):
13
+ if not app:
14
+ app = get_coloco_config().get("app") or "src.main.app"
15
+
16
+ _verify_app(app)
17
+
18
+ cwd = Path(os.getcwd())
19
+ source_dir = cwd / "src"
20
+ dist_dir = cwd / "dist"
21
+ api_dir = dist_dir / "src"
22
+ frontend_dir = dist_dir / "static"
23
+
24
+ print(f"Clearing {dist_dir}...")
25
+ shutil.rmtree(dist_dir, ignore_errors=True)
26
+
27
+ # # Codegen API
28
+ codegen(app)
29
+
30
+ # # Build node app
31
+ print(f"Packaging app...")
32
+ build_node(dir=frontend_dir)
33
+
34
+ print(f"Adding source files...")
35
+
36
+ api_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ # Collect backend files
39
+ package_api_files = get_coloco_config().get("package_api_files") or ["*.py"]
40
+ for glob_pattern in package_api_files:
41
+ # If glob_pattern starts with /, assume non-recursive
42
+ files = (
43
+ source_dir.glob(glob_pattern[1:])
44
+ if glob_pattern.startswith("/")
45
+ else source_dir.rglob(glob_pattern)
46
+ )
47
+ for file in files:
48
+ # Skip dev files
49
+ if "/-" in str(file):
50
+ continue
51
+
52
+ destination = api_dir / file.relative_to(source_dir)
53
+ destination.parent.mkdir(parents=True, exist_ok=True)
54
+ shutil.copy(file, destination)
55
+ print(f" |- {file.relative_to(cwd)}")
56
+
57
+ print(f"Adding project files...")
58
+ shutil.copy(cwd / "pyproject.toml", dist_dir / "pyproject.toml")
59
+ shutil.copy(cwd / "uv.lock", dist_dir / "uv.lock")
60
+
61
+ print(
62
+ f"App packaged into {dist_dir}.\n"
63
+ f"From the [yellow]{dist_dir}[/yellow] directory:\n"
64
+ f" Run [green]uv sync --frozen[/green] to install dependencies.\n"
65
+ f" Run [green]coloco serve[/green] to start the app in production mode.\n"
66
+ )
@@ -0,0 +1,144 @@
1
+ from .api import _verify_app
2
+ from ..app import ColocoApp, get_current_app
3
+ from asyncio import run
4
+ import cyclopts
5
+ import functools
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ from rich import print
10
+ from tortoise import Tortoise
11
+ from tortoise.log import logger as tortoise_logger
12
+ from tortoise_pathway.migration_manager import MigrationManager
13
+ from typing import Callable, TypeVar
14
+
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ app = cyclopts.App()
20
+
21
+
22
+ def _get_model_apps(coloco_app: ColocoApp):
23
+ return [app for app in coloco_app.orm_config["apps"]]
24
+
25
+
26
+ def _get_coloco_app():
27
+ _verify_app()
28
+ return get_current_app()
29
+
30
+
31
+ def _get_app_migrations_path_func(migrations_dir: str) -> Callable[[str], Path]:
32
+ def _get_app_migrations_path(self, app) -> Path:
33
+ return Path(os.path.join("src", "app", app, migrations_dir))
34
+
35
+ return _get_app_migrations_path
36
+
37
+
38
+ def db_command(func):
39
+ @functools.wraps(func)
40
+ def wrapper(*args, **kwargs):
41
+ tortoise_logger.setLevel(logging.WARNING)
42
+ run(func(*args, **kwargs))
43
+ run(Tortoise.close_connections())
44
+
45
+ return app.command(name=func.__name__)(wrapper)
46
+
47
+
48
+ async def get_migration_manager():
49
+ coloco_app = _get_coloco_app()
50
+ apps = _get_model_apps(coloco_app)
51
+ await Tortoise.init(config=coloco_app.orm_config)
52
+
53
+ # TODO: propose a patch for this
54
+ MigrationManager.get_migrations_dir = _get_app_migrations_path_func(coloco_app.migrations_dir)
55
+
56
+ manager = MigrationManager(apps, coloco_app.migrations_dir)
57
+ await manager.initialize()
58
+
59
+ return manager
60
+
61
+
62
+ # ----------------------------- Commands -----------------------------
63
+
64
+
65
+ @db_command
66
+ async def makemigrations(
67
+ app: str | None = None, name: str | None = None, empty: bool = False
68
+ ) -> None:
69
+ """Create new migration(s) based on model changes."""
70
+ manager = await get_migration_manager()
71
+
72
+ print(f"Making migrations for {app or 'all apps'}...", end="", flush=True)
73
+
74
+ migrations = await manager.create_migration(name, app=app, auto=True)
75
+
76
+ if not migrations:
77
+ print("[gray]no changes[/gray]")
78
+ return
79
+
80
+ print()
81
+ for migration in migrations:
82
+ print(f" |- [yellow]{migration.display_name()}[/yellow] @ {migration.path()}")
83
+
84
+
85
+ @db_command
86
+ async def migrate(app: str | None = None) -> None:
87
+ """Apply migrations to the database."""
88
+ manager = await get_migration_manager()
89
+
90
+ pending = manager.get_pending_migrations(app=app)
91
+
92
+ if not pending:
93
+ print("[gray]No pending migrations.[/gray]")
94
+ return
95
+
96
+ s = "s" if len(pending) > 1 else ""
97
+ print(f"Applying [cyan]{len(pending)}[/cyan] migration{s}:")
98
+
99
+ applied = []
100
+ async for migration in manager.apply_migrations_async(app=app):
101
+ print(f" |- [cyan]{migration.display_name()}[/cyan]")
102
+ applied.append(migration)
103
+
104
+ if applied:
105
+ print(f"[green]Successfully applied {len(applied)} migration{s}[/green]")
106
+ else:
107
+ print("[gray]No migrations were applied.[/gray]")
108
+
109
+
110
+ @db_command
111
+ async def rollback(app: str | None = None, migration: str | None = None) -> None:
112
+ """Revert the most recent migration."""
113
+ manager = await get_migration_manager()
114
+
115
+ reverted = await manager.revert_migration(migration=migration, app=app)
116
+
117
+ if reverted:
118
+ print(f"Successfully reverted migration: [yellow]{reverted.display_name()}[/yellow]")
119
+ else:
120
+ print("[gray]No migration was reverted.[/gray]")
121
+
122
+
123
+ @db_command
124
+ async def showmigrations(app: str | None = None) -> None:
125
+ """Show migration status."""
126
+ manager = await get_migration_manager()
127
+
128
+ applied = manager.get_applied_migrations(app=app)
129
+ pending = manager.get_pending_migrations(app=app)
130
+
131
+ print(f"Migrations for {app}:" if app else "All Migrations:")
132
+ print("\nApplied migrations:")
133
+ if applied:
134
+ for migration in applied:
135
+ print(f" [X] {migration.display_name()}")
136
+ else:
137
+ print(" (none)")
138
+
139
+ print("\nPending migrations:")
140
+ if pending:
141
+ for migration in pending:
142
+ print(f" [ ] {migration.display_name()}")
143
+ else:
144
+ print(" (none)")
@@ -1,24 +1,28 @@
1
1
  from .api import _verify_app, _serve
2
+ import cyclopts
2
3
  from ..config import get_coloco_config
3
4
  from .node import install, _setup_dev_env
4
5
  import os
5
6
  from rich import print
6
7
  from subprocess import Popen
7
8
 
9
+ app = cyclopts.App()
8
10
 
9
- def dev(app: str = None, host: str = "127.0.0.1"):
11
+
12
+ @app.default()
13
+ def dev(app: str | None = None, host: str = "127.0.0.1"):
10
14
  if not app:
11
15
  app = get_coloco_config().get("app") or "src.main.app"
12
16
 
13
17
  _verify_app(app)
14
18
 
15
- # Check Node Modules
16
- if not os.path.exists(os.path.join(os.getcwd(), "node_modules")):
17
- print("[yellow]Node modules not found, installing...[/yellow]")
18
- install()
19
+ # # Check Node Modules
20
+ # if not os.path.exists(os.path.join(os.getcwd(), "node_modules")):
21
+ # print("[yellow]Node modules not found, installing...[/yellow]")
22
+ # install()
19
23
 
20
- _setup_dev_env()
21
- node = Popen([f"npm run dev"], shell=True)
24
+ # _setup_dev_env()
25
+ # node = Popen([f"npm run dev"], shell=True)
22
26
  _serve(
23
27
  app=app,
24
28
  host=host,
@@ -1,10 +1,10 @@
1
- import typer
1
+ import cyclopts
2
2
  import os
3
3
  from rich import print
4
4
  import shutil
5
5
  import subprocess
6
6
 
7
- app = typer.Typer()
7
+ app = cyclopts.App()
8
8
 
9
9
 
10
10
  def _run_npm(command):
@@ -13,14 +13,14 @@ def _run_npm(command):
13
13
  print(
14
14
  "[red]Error: package.json not found. Please ensure you are in a coloco project directory.[/red]"
15
15
  )
16
- raise typer.Abort()
16
+ raise SystemExit(1)
17
17
 
18
18
  try:
19
19
  # run npm install
20
20
  subprocess.run(command, cwd=".")
21
21
  except Exception as e:
22
22
  print(f"[red]Error: {e}[/red]")
23
- raise typer.Abort()
23
+ raise SystemExit(1)
24
24
 
25
25
 
26
26
  def _setup_dev_env():
@@ -63,8 +63,8 @@ def dev():
63
63
 
64
64
 
65
65
  @app.command()
66
- def build(dir: str = None):
66
+ def build(dir: str | None = None):
67
67
  """Runs the node dev server"""
68
68
  print("[green]Building node app...[/green]")
69
69
 
70
- subprocess.run(["npm", "run", "build"], cwd=".")
70
+ subprocess.run(["npm", "run", "build", *(["--", "--outDir", dir] if dir else [])], cwd=".")
@@ -1,14 +1,14 @@
1
- from rich import print
2
1
  from .api import _serve
3
- import os
4
- import typer
5
- import uvicorn
2
+ from ..config import get_coloco_config
6
3
 
7
4
 
8
5
  def serve(
9
- app: str = "main.app",
6
+ app: str | None = None,
10
7
  port: int = 80,
11
8
  host: str = "0.0.0.0",
12
9
  log_level: str = "info",
13
10
  ):
11
+ if not app:
12
+ app = get_coloco_config().get("app") or "src.main.app"
13
+
14
14
  return _serve(app=app, host=host, port=port, log_level=log_level, mode="prod")
@@ -0,0 +1,143 @@
1
+ from .api import ColocoRoute
2
+ from contextlib import asynccontextmanager
3
+ from collections import defaultdict
4
+ from functools import wraps
5
+ from .lifespan import register_lifespan
6
+ from rich import print
7
+ from type_less import replace_type_hint_map_deep
8
+ from typing import Callable, Type, TypeVar, Optional, Any
9
+ from pydantic import ConfigDict
10
+
11
+
12
+ CLS = TypeVar("CLS")
13
+
14
+
15
+ IMPORT_ERROR = (
16
+ "[red]Tortoise is not installed. "
17
+ "Please install it with `uv add tortoise-orm`. "
18
+ "If you intend to use anything other than sqlite, "
19
+ "you will need to install the appropriate database driver as well "
20
+ "(e.g. `uv add tortoise-orm[asyncpg]` for postgres).[/red]"
21
+ )
22
+
23
+
24
+ def app_class_to_table_name(cls):
25
+ try:
26
+ app_name = cls.__module__.split(".")[-2]
27
+ except IndexError:
28
+ raise ValueError(f"Could not determine app name for model {cls}")
29
+ return f"{app_name}_{cls.__name__.lower()}"
30
+
31
+
32
+ def get_orm_config(database_url: str, model_files: list[str]):
33
+ model_modules = [
34
+ model_file.replace("./", "").replace("/", ".").replace(".py", "")
35
+ for model_file in model_files
36
+ ]
37
+ app_to_models = defaultdict(list)
38
+ for model_module in model_modules:
39
+ app = model_module.lstrip("src.app.").split(".")[0]
40
+ app_to_models[app].append(model_module)
41
+ return {
42
+ "connections": {"default": database_url},
43
+ "table_name_generator": app_class_to_table_name,
44
+ "apps": {
45
+ app: {
46
+ "models": [
47
+ *models,
48
+ ],
49
+ "default_connection": "default",
50
+ }
51
+ for app, models in app_to_models.items()
52
+ },
53
+ }
54
+
55
+
56
+ async def init_tortoise(app):
57
+ try:
58
+ from tortoise import Tortoise
59
+ except ImportError:
60
+ print(IMPORT_ERROR)
61
+ raise
62
+ await Tortoise.init(config=app.orm_config, table_name_generator=app_class_to_table_name)
63
+ return Tortoise.close_connections
64
+
65
+
66
+ def register_db_lifecycle(app):
67
+ @asynccontextmanager
68
+ async def lifecycle_connect_database(api):
69
+ print("[green]Connecting to database...[/green]")
70
+ close_connections = await init_tortoise(app)
71
+ print("[green]Database ready[/green]")
72
+ yield
73
+ print("[yellow]Closing database connection...[/yellow]")
74
+ await close_connections()
75
+
76
+ # Register DB Connection
77
+ register_lifespan(lifecycle_connect_database)
78
+
79
+
80
+ def inject_model_serializers(orm_config: dict, routes: list[ColocoRoute]):
81
+ try:
82
+ from tortoise import Tortoise
83
+ from tortoise.contrib.pydantic import pydantic_model_creator
84
+ except ImportError:
85
+ print(IMPORT_ERROR)
86
+ raise
87
+
88
+ # TODO: find out if we can init the config without initing the DB
89
+ Tortoise.table_name_generator = app_class_to_table_name
90
+ for label, _app in orm_config["apps"].items():
91
+ for model in _app["models"]:
92
+ Tortoise.init_models([model], label)
93
+
94
+ # Auto-create serializers
95
+ # TODO: configurable
96
+ model_classes = [cls for models in Tortoise.apps.values() for cls in models.values()]
97
+ model_to_serializer = {}
98
+ for model_class in model_classes:
99
+ # Wrapped with @serializable
100
+ if getattr(model_class, "__model_serializer_create", None):
101
+ model_class.__model_serializer = pydantic_model_creator(
102
+ model_class, **model_class.__model_serializer_create
103
+ )
104
+ # Create a general serializer
105
+ elif not getattr(model_class, "__model_serializer", None):
106
+ model_class.__model_serializer = pydantic_model_creator(model_class)
107
+ model_to_serializer[model_class] = model_class.__model_serializer
108
+
109
+ for route in routes:
110
+ route.func.__annotations__["return"], occurrences = replace_type_hint_map_deep(
111
+ route.func.__annotations__["return"],
112
+ model_to_serializer,
113
+ )
114
+
115
+ # Magical model adapting (when models detected)
116
+ # TODO: find a better way to do this than wrapping every route
117
+ # We need to async eval the serialization
118
+ # - Middleware - can't catch pre-serialized values
119
+ # - Pydantic - sync serialization only
120
+ # - JSON Encoder - sync serialization only, + happens after serialization
121
+ if occurrences:
122
+ route.func = _wrap_model_serializer(route.func)
123
+
124
+
125
+ async def _serialize_models(obj):
126
+ """
127
+ Auto-runs the serializer for tortoise models
128
+ """
129
+ if hasattr(obj, "__model_serializer"):
130
+ return await obj.__model_serializer.from_tortoise_orm(obj)
131
+ elif isinstance(obj, list):
132
+ return [await _serialize_models(item) for item in obj]
133
+ elif isinstance(obj, dict):
134
+ return {k: await _serialize_models(v) for k, v in obj.items()}
135
+ return obj
136
+
137
+
138
+ def _wrap_model_serializer(func):
139
+ @wraps(func)
140
+ async def wrapper(*args, **kwargs):
141
+ return await _serialize_models(await func(*args, **kwargs))
142
+
143
+ return wrapper
@@ -0,0 +1,6 @@
1
+ from fastapi import Depends
2
+ from typing import Annotated
3
+
4
+ from .app import ColocoApp as ColocoAppType, get_current_app
5
+
6
+ ColocoApp = Annotated[ColocoAppType, Depends(get_current_app)]
@@ -4,9 +4,9 @@ from fastapi.staticfiles import StaticFiles
4
4
 
5
5
 
6
6
  # Static Files
7
- def bind_static(api: FastAPI, dist_dir: str = "dist"):
8
- api.mount("/assets", StaticFiles(directory=f"{dist_dir}/app/assets"), name="static")
7
+ def bind_static(api: FastAPI, dist_dir: str = "."):
8
+ api.mount("/assets", StaticFiles(directory=f"{dist_dir}/static/assets"), name="static")
9
9
 
10
10
  @api.get("/{all_paths:path}")
11
11
  async def serve_app(all_paths: str):
12
- return FileResponse(f"{dist_dir}/app/index.html")
12
+ return FileResponse(f"{dist_dir}/static/index.html")
@@ -1,22 +0,0 @@
1
- from rich import print
2
- from .api import _verify_app, codegen
3
- from .node import build as build_node
4
- import os
5
-
6
-
7
- def build(
8
- app: str = "main.app",
9
- ):
10
- _verify_app(app)
11
- dist_dir = f"{os.getcwd()}/dist"
12
- print(f"Packaging {app}...")
13
-
14
- # Codegen API
15
- codegen(app)
16
- # Build node app
17
- build_node(dir=dist_dir)
18
-
19
- print(
20
- f"App packaged into {dist_dir}.\n"
21
- f"Run [green]coloco serve[/green] to start the app in production mode."
22
- )
@@ -1,100 +0,0 @@
1
- from aerich import Command
2
- from .api import _verify_app
3
- from ..app import ColocoApp, get_current_app
4
- from asyncio import run
5
- import functools
6
- import os
7
- from rich import print
8
- from tortoise import Tortoise
9
- import typer
10
-
11
- app = typer.Typer()
12
-
13
- def get_app():
14
- _verify_app()
15
- return get_current_app()
16
-
17
- async def get_command(app: ColocoApp = None, init: bool = True):
18
- app = app or get_app()
19
- command = Command(
20
- tortoise_config=app.orm_config,
21
- app="models",
22
- location=app.migrations_dir,
23
- )
24
- if init:
25
- await command.init()
26
- return command
27
-
28
- def migrations_dir_exists(migrations_dir: str):
29
- migrations_dir = os.path.join(migrations_dir, "models")
30
- return os.path.exists(migrations_dir)
31
-
32
- def ensure_migrations_dir(migrations_dir: str):
33
- if not migrations_dir_exists(migrations_dir):
34
- print("[red]Please run `coloco db init` first to initialize the database.[/red]")
35
- raise typer.Exit(code=1)
36
-
37
- def db_command(func):
38
- @functools.wraps(func)
39
- def wrapper(*args, **kwargs):
40
- run(func(*args, **kwargs))
41
- run(Tortoise.close_connections())
42
- return wrapper
43
-
44
- @app.command()
45
- @db_command
46
- async def makemigrations():
47
- app = get_app()
48
- ensure_migrations_dir(app.migrations_dir)
49
- command = await get_command(app)
50
- migrations = await command.migrate(name="update", empty=False)
51
- if migrations:
52
- print("[green]Migrations created successfully.[/green]")
53
- else:
54
- print("[gray]No database changes detected.[/gray]")
55
-
56
- @app.command()
57
- @db_command
58
- async def migrate():
59
- app = get_app()
60
- ensure_migrations_dir(app.migrations_dir)
61
- command = await get_command(app)
62
- migrations = await command.upgrade(run_in_transaction=True)
63
- if migrations:
64
- print("[green]Database migrated successfully.[/green]")
65
- else:
66
- print("[gray]No database changes detected.[/gray]")
67
-
68
- @app.command()
69
- @db_command
70
- async def init():
71
- app = get_app()
72
- if migrations_dir_exists(app.migrations_dir):
73
- print("[red]Migrations directory already exists. You may want to run `coloco db migrate` instead.[/red]")
74
- raise typer.Exit(code=1)
75
- command = await get_command(app, init=False)
76
- await command.init_db(safe=True)
77
- print("[green]Database initialized successfully.[/green]")
78
-
79
- @app.command()
80
- @db_command
81
- async def revert(version: str, fake: bool = False):
82
- app = get_app()
83
- ensure_migrations_dir(app.migrations_dir)
84
- command = await get_command(app)
85
- await command.downgrade(version=version, delete=False, fake=fake)
86
-
87
-
88
- @app.command()
89
- @db_command
90
- async def heads():
91
- command = await get_command()
92
- await command.heads()
93
-
94
- @app.command()
95
- @db_command
96
- async def history():
97
- command = await get_command()
98
- await command.history()
99
-
100
-
@@ -1,39 +0,0 @@
1
- from contextlib import asynccontextmanager
2
- from rich import print
3
-
4
- def get_orm_config(database_url: str, model_files: list[str]):
5
- return {
6
- "connections": {
7
- "default": database_url
8
- },
9
- "apps": {
10
- "models": {
11
- "models": [
12
- *[model_file.replace("./", "").replace("/", ".").replace(".py", "") for model_file in model_files],
13
- "aerich.models",
14
- ],
15
- "default_connection": "default",
16
- },
17
- },
18
- }
19
-
20
- @asynccontextmanager
21
- async def lifecycle_connect_database(api):
22
- from .app import get_current_app
23
- app = get_current_app()
24
-
25
- try:
26
- from tortoise import Tortoise
27
- except ImportError:
28
- print("[red]Tortoise is not installed. "
29
- "Please install it with `pip install tortoise-orm`. "
30
- "If you intend to use anything other than sqlite, "
31
- "you will need to install the appropriate database driver as well "
32
- "(e.g. `pip install tortoise-orm[asyncpg]` for postgres).[/red]")
33
- raise
34
- print("[green]Connecting to database...[/green]")
35
- await Tortoise.init(config=app.orm_config)
36
- print("[green]Database ready[/green]")
37
- yield
38
- print("[yellow]Closing database connection...[/yellow]")
39
- await Tortoise.close_connections()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes