ohmyapi 0.1.19__tar.gz → 0.1.21__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 (25) hide show
  1. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/PKG-INFO +21 -13
  2. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/README.md +20 -12
  3. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/pyproject.toml +23 -1
  4. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/__init__.py +0 -1
  5. ohmyapi-0.1.21/src/ohmyapi/builtin/auth/__init__.py +1 -0
  6. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/builtin/auth/models.py +12 -7
  7. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/builtin/auth/routes.py +52 -28
  8. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/cli.py +42 -38
  9. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/runtime.py +56 -24
  10. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/scaffolding.py +22 -6
  11. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/app/routes.py.j2 +1 -1
  12. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/db/exceptions.py +0 -1
  13. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/db/model/model.py +27 -2
  14. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/router.py +0 -1
  15. ohmyapi-0.1.19/src/ohmyapi/builtin/auth/__init__.py +0 -4
  16. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/__main__.py +0 -0
  17. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/builtin/auth/permissions.py +3 -3
  18. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/__init__.py +0 -0
  19. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/app/__init__.py.j2 +0 -0
  20. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/app/models.py.j2 +0 -0
  21. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/project/README.md.j2 +0 -0
  22. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/project/pyproject.toml.j2 +0 -0
  23. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/project/settings.py.j2 +0 -0
  24. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/db/__init__.py +3 -3
  25. {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/db/model/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ohmyapi
3
- Version: 0.1.19
3
+ Version: 0.1.21
4
4
  Summary: A Django-like but async web-framework based on FastAPI and TortoiseORM.
5
5
  License-Expression: MIT
6
6
  Keywords: fastapi,tortoise,orm,async,web-framework
@@ -118,31 +118,35 @@ Write your first model in `turnament/models.py`:
118
118
  ```python
119
119
  from ohmyapi.db import Model, field
120
120
 
121
+ from datetime import datetime
122
+ from decimal import Decimal
123
+ from uuid import UUID
124
+
121
125
 
122
126
  class Tournament(Model):
123
- id = field.data.UUIDField(primary_key=True)
124
- name = field.TextField()
125
- created = field.DatetimeField(auto_now_add=True)
127
+ id: UUID = field.data.UUIDField(primary_key=True)
128
+ name: str = field.TextField()
129
+ created: datetime = field.DatetimeField(auto_now_add=True)
126
130
 
127
131
  def __str__(self):
128
132
  return self.name
129
133
 
130
134
 
131
135
  class Event(Model):
132
- id = field.data.UUIDField(primary_key=True)
133
- name = field.TextField()
134
- tournament = field.ForeignKeyField('tournament.Tournament', related_name='events')
135
- participants = field.ManyToManyField('tournament.Team', related_name='events', through='event_team')
136
- modified = field.DatetimeField(auto_now=True)
137
- prize = field.DecimalField(max_digits=10, decimal_places=2, null=True)
136
+ id: UUID = field.data.UUIDField(primary_key=True)
137
+ name: str = field.TextField()
138
+ tournament: UUID = field.ForeignKeyField('tournament.Tournament', related_name='events')
139
+ participants: field.ManyToManyRelation[Team] = field.ManyToManyField('tournament.Team', related_name='events', through='event_team')
140
+ modified: datetime = field.DatetimeField(auto_now=True)
141
+ prize: Decimal = field.DecimalField(max_digits=10, decimal_places=2, null=True)
138
142
 
139
143
  def __str__(self):
140
144
  return self.name
141
145
 
142
146
 
143
147
  class Team(Model):
144
- id = field.data.UUIDField(primary_key=True)
145
- name = field.TextField()
148
+ id: UUID = field.data.UUIDField(primary_key=True)
149
+ name: str = field.TextField()
146
150
 
147
151
  def __str__(self):
148
152
  return self.name
@@ -242,9 +246,13 @@ It comes with a `User` and `Group` model, as well as endpoints for JWT auth.
242
246
  You can use the models as `ForeignKeyField` in your application models:
243
247
 
244
248
  ```python
249
+ from ohmyapi.db import Model, field
250
+ from ohmyapi_auth.models import User
251
+
252
+
245
253
  class Team(Model):
246
254
  [...]
247
- members = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams')
255
+ members: field.ManyToManyRelation[User] = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams')
248
256
  [...]
249
257
  ```
250
258
 
@@ -86,31 +86,35 @@ Write your first model in `turnament/models.py`:
86
86
  ```python
87
87
  from ohmyapi.db import Model, field
88
88
 
89
+ from datetime import datetime
90
+ from decimal import Decimal
91
+ from uuid import UUID
92
+
89
93
 
90
94
  class Tournament(Model):
91
- id = field.data.UUIDField(primary_key=True)
92
- name = field.TextField()
93
- created = field.DatetimeField(auto_now_add=True)
95
+ id: UUID = field.data.UUIDField(primary_key=True)
96
+ name: str = field.TextField()
97
+ created: datetime = field.DatetimeField(auto_now_add=True)
94
98
 
95
99
  def __str__(self):
96
100
  return self.name
97
101
 
98
102
 
99
103
  class Event(Model):
100
- id = field.data.UUIDField(primary_key=True)
101
- name = field.TextField()
102
- tournament = field.ForeignKeyField('tournament.Tournament', related_name='events')
103
- participants = field.ManyToManyField('tournament.Team', related_name='events', through='event_team')
104
- modified = field.DatetimeField(auto_now=True)
105
- prize = field.DecimalField(max_digits=10, decimal_places=2, null=True)
104
+ id: UUID = field.data.UUIDField(primary_key=True)
105
+ name: str = field.TextField()
106
+ tournament: UUID = field.ForeignKeyField('tournament.Tournament', related_name='events')
107
+ participants: field.ManyToManyRelation[Team] = field.ManyToManyField('tournament.Team', related_name='events', through='event_team')
108
+ modified: datetime = field.DatetimeField(auto_now=True)
109
+ prize: Decimal = field.DecimalField(max_digits=10, decimal_places=2, null=True)
106
110
 
107
111
  def __str__(self):
108
112
  return self.name
109
113
 
110
114
 
111
115
  class Team(Model):
112
- id = field.data.UUIDField(primary_key=True)
113
- name = field.TextField()
116
+ id: UUID = field.data.UUIDField(primary_key=True)
117
+ name: str = field.TextField()
114
118
 
115
119
  def __str__(self):
116
120
  return self.name
@@ -210,9 +214,13 @@ It comes with a `User` and `Group` model, as well as endpoints for JWT auth.
210
214
  You can use the models as `ForeignKeyField` in your application models:
211
215
 
212
216
  ```python
217
+ from ohmyapi.db import Model, field
218
+ from ohmyapi_auth.models import User
219
+
220
+
213
221
  class Team(Model):
214
222
  [...]
215
- members = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams')
223
+ members: field.ManyToManyRelation[User] = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams')
216
224
  [...]
217
225
  ```
218
226
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ohmyapi"
3
- version = "0.1.19"
3
+ version = "0.1.21"
4
4
  description = "A Django-like but async web-framework based on FastAPI and TortoiseORM."
5
5
  license = "MIT"
6
6
  keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"]
@@ -27,6 +27,8 @@ dependencies = [
27
27
 
28
28
  [tool.poetry.group.dev.dependencies]
29
29
  ipython = ">=9.5.0,<10.0.0"
30
+ black = "^25.9.0"
31
+ isort = "^6.0.1"
30
32
 
31
33
  [project.optional-dependencies]
32
34
  auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"]
@@ -36,3 +38,23 @@ packages = [ { include = "ohmyapi", from = "src" } ]
36
38
 
37
39
  [project.scripts]
38
40
  ohmyapi = "ohmyapi.cli:app"
41
+
42
+ [tool.black]
43
+ line-length = 88
44
+ target-version = ['py39', 'py310', 'py311', 'py312', 'py313']
45
+ include = '\.pyi?$'
46
+ exclude = '''
47
+ /(
48
+ \.git
49
+ | \.venv
50
+ | build
51
+ | dist
52
+ )/
53
+ '''
54
+
55
+ [tool.isort]
56
+ profile = "black" # makes imports compatible with black
57
+ line_length = 88
58
+ multi_line_output = 3
59
+ include_trailing_comma = true
60
+
@@ -1,2 +1 @@
1
1
  from . import db
2
-
@@ -0,0 +1 @@
1
+ from . import models, permissions, routes
@@ -1,29 +1,34 @@
1
1
  from functools import wraps
2
- from typing import Optional, List
3
- from ohmyapi.router import HTTPException
4
- from ohmyapi.db import Model, field, pre_save, pre_delete
2
+ from typing import List, Optional
3
+ from uuid import UUID
4
+
5
5
  from passlib.context import CryptContext
6
6
  from tortoise.contrib.pydantic import pydantic_queryset_creator
7
7
 
8
+ from ohmyapi.db import Model, field, pre_delete, pre_save
9
+ from ohmyapi.router import HTTPException
10
+
8
11
  pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
9
12
 
10
13
 
11
14
  class Group(Model):
12
- id: str = field.data.UUIDField(pk=True)
15
+ id: UUID = field.data.UUIDField(pk=True)
13
16
  name: str = field.CharField(max_length=42, index=True)
14
17
 
15
18
 
16
19
  class User(Model):
17
- id: str = field.data.UUIDField(pk=True)
20
+ id: UUID = field.data.UUIDField(pk=True)
18
21
  email: str = field.CharField(max_length=255, unique=True, index=True)
19
22
  username: str = field.CharField(max_length=150, unique=True)
20
23
  password_hash: str = field.CharField(max_length=128)
21
24
  is_admin: bool = field.BooleanField(default=False)
22
25
  is_staff: bool = field.BooleanField(default=False)
23
- groups: field.ManyToManyRelation[Group] = field.ManyToManyField("ohmyapi_auth.Group", related_name="users", through='user_groups')
26
+ groups: field.ManyToManyRelation[Group] = field.ManyToManyField(
27
+ "ohmyapi_auth.Group", related_name="users", through="user_groups"
28
+ )
24
29
 
25
30
  class Schema:
26
- exclude = 'password_hash',
31
+ exclude = ("password_hash",)
27
32
 
28
33
  def set_password(self, raw_password: str) -> None:
29
34
  """Hash and store the password."""
@@ -3,13 +3,12 @@ from enum import Enum
3
3
  from typing import Any, Dict, List
4
4
 
5
5
  import jwt
6
+ import settings
6
7
  from fastapi import APIRouter, Body, Depends, Header, HTTPException, status
7
8
  from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
8
9
  from pydantic import BaseModel
9
10
 
10
- from ohmyapi.builtin.auth.models import User, Group
11
-
12
- import settings
11
+ from ohmyapi.builtin.auth.models import Group, User
13
12
 
14
13
  # Router
15
14
  router = APIRouter(prefix="/auth", tags=["auth"])
@@ -17,8 +16,12 @@ router = APIRouter(prefix="/auth", tags=["auth"])
17
16
  # Secrets & config (should come from settings/env in real projects)
18
17
  JWT_SECRET = getattr(settings, "JWT_SECRET", "changeme")
19
18
  JWT_ALGORITHM = getattr(settings, "JWT_ALGORITHM", "HS256")
20
- ACCESS_TOKEN_EXPIRE_SECONDS = getattr(settings, "JWT_ACCESS_TOKEN_EXPIRE_SECONDS", 15 * 60)
21
- REFRESH_TOKEN_EXPIRE_SECONDS = getattr(settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60)
19
+ ACCESS_TOKEN_EXPIRE_SECONDS = getattr(
20
+ settings, "JWT_ACCESS_TOKEN_EXPIRE_SECONDS", 15 * 60
21
+ )
22
+ REFRESH_TOKEN_EXPIRE_SECONDS = getattr(
23
+ settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60
24
+ )
22
25
 
23
26
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
24
27
 
@@ -36,30 +39,38 @@ def decode_token(token: str) -> Dict:
36
39
  try:
37
40
  return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
38
41
  except jwt.ExpiredSignatureError:
39
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
42
+ raise HTTPException(
43
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
44
+ )
40
45
  except jwt.InvalidTokenError:
41
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
46
+ raise HTTPException(
47
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
48
+ )
42
49
 
43
50
 
44
51
  class TokenType(str, Enum):
45
52
  """
46
53
  Helper for indicating the token type when generating claims.
47
54
  """
55
+
48
56
  access = "access"
49
57
  refresh = "refresh"
50
58
 
51
59
 
52
- def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Dict[str, Any]:
60
+ def claims(
61
+ token_type: TokenType, user: User, groups: List[Group] = []
62
+ ) -> Dict[str, Any]:
53
63
  return {
54
- 'type': token_type,
55
- 'sub': str(user.id),
56
- 'user': {
57
- 'username': user.username,
58
- 'email': user.email,
64
+ "type": token_type,
65
+ "sub": str(user.id),
66
+ "user": {
67
+ "username": user.username,
68
+ "email": user.email,
59
69
  },
60
- 'roles': [g.name for g in groups]
70
+ "roles": [g.name for g in groups],
61
71
  }
62
72
 
73
+
63
74
  async def get_token(token: str = Depends(oauth2_scheme)) -> Dict:
64
75
  """Dependency: token introspection"""
65
76
  payload = decode_token(token)
@@ -71,11 +82,15 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
71
82
  payload = decode_token(token)
72
83
  user_id = payload.get("sub")
73
84
  if user_id is None:
74
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
85
+ raise HTTPException(
86
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload"
87
+ )
75
88
 
76
89
  user = await User.filter(id=user_id).first()
77
90
  if not user:
78
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
91
+ raise HTTPException(
92
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
93
+ )
79
94
  return user
80
95
 
81
96
 
@@ -101,15 +116,13 @@ async def require_staff(current_user: User = Depends(get_current_user)) -> User:
101
116
 
102
117
 
103
118
  async def require_group(
104
- group_name: str,
105
- current_user: User = Depends(get_current_user)
119
+ group_name: str, current_user: User = Depends(get_current_user)
106
120
  ) -> User:
107
121
  """Ensure the current user belongs to the given group."""
108
122
  user_groups = await current_user.groups.all()
109
123
  if not any(g.name == group_name for g in user_groups):
110
124
  raise HTTPException(
111
- status_code=403,
112
- detail=f"User must belong to group '{group_name}'"
125
+ status_code=403, detail=f"User must belong to group '{group_name}'"
113
126
  )
114
127
  return current_user
115
128
 
@@ -124,15 +137,21 @@ async def login(form_data: LoginRequest = Body(...)):
124
137
  """Login with username & password, returns access and refresh tokens."""
125
138
  user = await User.authenticate(form_data.username, form_data.password)
126
139
  if not user:
127
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
140
+ raise HTTPException(
141
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
142
+ )
128
143
 
129
- access_token = create_token(claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS)
130
- refresh_token = create_token(claims(TokenType.refresh, user), REFRESH_TOKEN_EXPIRE_SECONDS)
144
+ access_token = create_token(
145
+ claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
146
+ )
147
+ refresh_token = create_token(
148
+ claims(TokenType.refresh, user), REFRESH_TOKEN_EXPIRE_SECONDS
149
+ )
131
150
 
132
151
  return {
133
152
  "access_token": access_token,
134
153
  "refresh_token": refresh_token,
135
- "token_type": "bearer"
154
+ "token_type": "bearer",
136
155
  }
137
156
 
138
157
 
@@ -141,14 +160,20 @@ async def refresh_token(refresh_token: str):
141
160
  """Exchange refresh token for new access token."""
142
161
  payload = decode_token(refresh_token)
143
162
  if payload.get("type") != "refresh":
144
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
163
+ raise HTTPException(
164
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
165
+ )
145
166
 
146
167
  user_id = payload.get("sub")
147
168
  user = await User.filter(id=user_id).first()
148
169
  if not user:
149
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
170
+ raise HTTPException(
171
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
172
+ )
150
173
 
151
- new_access = create_token(claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS)
174
+ new_access = create_token(
175
+ claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
176
+ )
152
177
  return {"access_token": new_access, "token_type": "bearer"}
153
178
 
154
179
 
@@ -161,4 +186,3 @@ async def introspect(token: Dict = Depends(get_token)):
161
186
  async def me(user: User = Depends(get_current_user)):
162
187
  """Return the currently authenticated user."""
163
188
  return User.Schema.one.from_orm(user)
164
-
@@ -2,17 +2,17 @@ import asyncio
2
2
  import atexit
3
3
  import importlib
4
4
  import sys
5
+ from getpass import getpass
6
+ from pathlib import Path
7
+
5
8
  import typer
6
9
  import uvicorn
7
10
 
8
- from getpass import getpass
9
- from ohmyapi.core import scaffolding, runtime
10
- from pathlib import Path
11
+ from ohmyapi.core import runtime, scaffolding
11
12
 
12
- app = typer.Typer(help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM.")
13
- banner = """OhMyAPI Shell | Project: {project_name}
14
- Find your loaded project singleton via identifier: `p`
15
- """
13
+ app = typer.Typer(
14
+ help="OhMyAPI Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM."
15
+ )
16
16
 
17
17
 
18
18
  @app.command()
@@ -40,52 +40,51 @@ def serve(root: str = ".", host="127.0.0.1", port=8000):
40
40
 
41
41
  @app.command()
42
42
  def shell(root: str = "."):
43
- """
44
- Launch an interactive IPython shell with the project and apps loaded.
45
- """
46
43
  project_path = Path(root).resolve()
47
44
  project = runtime.Project(project_path)
48
45
 
49
- # Ensure the ORM is shutdown
50
- async def close_project():
46
+ banner = f"""
47
+ OhMyAPI Project Shell: {getattr(project.settings, 'PROJECT_NAME', 'MyProject')}
48
+ Find your loaded project singleton via identifier: `p`; i.e.: `p.apps`
49
+ """
50
+
51
+ async def init_and_cleanup():
52
+ try:
53
+ await project.init_orm()
54
+ return True
55
+ except Exception as e:
56
+ print(f"Failed to initialize ORM: {e}")
57
+ return False
58
+
59
+ async def cleanup():
51
60
  try:
52
61
  await project.close_orm()
53
62
  print("Tortoise ORM closed successfully.")
54
63
  except Exception as e:
55
64
  print(f"Error closing ORM: {e}")
56
65
 
57
- def cleanup():
58
- loop = None
59
- try:
60
- loop = asyncio.get_running_loop()
61
- except RuntimeError:
62
- pass
63
- if loop and loop.is_running():
64
- asyncio.create_task(close_project())
65
- else:
66
- asyncio.run(close_project())
67
-
68
- # Ensure the ORM is initialized
69
- asyncio.run(project.init_orm())
66
+ loop = asyncio.new_event_loop()
67
+ asyncio.set_event_loop(loop)
68
+ loop.run_until_complete(init_and_cleanup())
69
+
70
+ # Prepare shell vars that are to be directly available
71
+ shell_vars = {"p": project}
70
72
 
71
73
  try:
72
74
  from IPython import start_ipython
73
- shell_vars = {
74
- "p": project,
75
- }
76
75
  from traitlets.config.loader import Config
76
+
77
77
  c = Config()
78
78
  c.TerminalIPythonApp.display_banner = True
79
- c.TerminalInteractiveShell.banner2 = banner.format(**{
80
- "project_name": f"{f'{project.settings.PROJECT_NAME} ' if getattr(project.settings, 'PROJECT_NAME', '') else ''}[{Path(project_path).resolve()}]",
81
- })
82
- atexit.register(cleanup)
79
+ c.TerminalInteractiveShell.banner2 = banner
80
+
83
81
  start_ipython(argv=[], user_ns=shell_vars, config=c)
84
82
  except ImportError:
85
- typer.echo("IPython is not installed. Falling back to built-in Python shell.")
86
83
  import code
87
- atexit.register(cleanup)
88
- code.interact(local={"p": project})
84
+
85
+ code.interact(local=shell_vars, banner=banner)
86
+ finally:
87
+ loop.run_until_complete(cleanup())
89
88
 
90
89
 
91
90
  @app.command()
@@ -125,11 +124,15 @@ def createsuperuser(root: str = "."):
125
124
  project_path = Path(root).resolve()
126
125
  project = runtime.Project(project_path)
127
126
  if not project.is_app_installed("ohmyapi_auth"):
128
- print("Auth app not installed! Please add 'ohmyapi_auth' to your INSTALLED_APPS.")
127
+ print(
128
+ "Auth app not installed! Please add 'ohmyapi_auth' to your INSTALLED_APPS."
129
+ )
129
130
  return
130
131
 
131
132
  import asyncio
133
+
132
134
  import ohmyapi_auth
135
+
133
136
  email = input("E-Mail: ")
134
137
  username = input("Username: ")
135
138
  password1, password2 = "foo", "bar"
@@ -138,9 +141,10 @@ def createsuperuser(root: str = "."):
138
141
  password2 = getpass("Repeat Password: ")
139
142
  if password1 != password2:
140
143
  print("Passwords didn't match!")
141
- user = ohmyapi_auth.models.User(email=email, username=username, is_staff=True, is_admin=True)
144
+ user = ohmyapi_auth.models.User(
145
+ email=email, username=username, is_staff=True, is_admin=True
146
+ )
142
147
  user.set_password(password1)
143
148
  asyncio.run(project.init_orm())
144
149
  asyncio.run(user.save())
145
150
  asyncio.run(project.close_orm())
146
-
@@ -2,16 +2,18 @@
2
2
  import copy
3
3
  import importlib
4
4
  import importlib.util
5
+ import json
5
6
  import pkgutil
6
7
  import sys
7
8
  from pathlib import Path
8
- from typing import Dict, List, Optional
9
+ from typing import Any, Dict, Generator, List, Optional
9
10
 
10
11
  import click
11
12
  from aerich import Command as AerichCommand
12
13
  from aerich.exceptions import NotInitedError
14
+ from fastapi import APIRouter, FastAPI
13
15
  from tortoise import Tortoise
14
- from fastapi import FastAPI, APIRouter
16
+
15
17
  from ohmyapi.db.model import Model
16
18
 
17
19
 
@@ -43,7 +45,9 @@ class Project:
43
45
  orig = importlib.import_module(full)
44
46
  sys.modules[alias] = orig
45
47
  try:
46
- sys.modules[f"{alias}.models"] = importlib.import_module(f"{full}.models")
48
+ sys.modules[f"{alias}.models"] = importlib.import_module(
49
+ f"{full}.models"
50
+ )
47
51
  except ModuleNotFoundError:
48
52
  pass
49
53
 
@@ -51,7 +55,9 @@ class Project:
51
55
  try:
52
56
  self.settings = importlib.import_module("settings")
53
57
  except Exception as e:
54
- raise RuntimeError(f"Failed to import project settings from {self.project_path}") from e
58
+ raise RuntimeError(
59
+ f"Failed to import project settings from {self.project_path}"
60
+ ) from e
55
61
 
56
62
  # Load installed apps
57
63
  for app_name in getattr(self.settings, "INSTALLED_APPS", []):
@@ -103,11 +109,16 @@ class Project:
103
109
  for app_name, app in self._apps.items():
104
110
  modules = list(dict.fromkeys(app.model_modules))
105
111
  if modules:
106
- config["apps"][app_name] = {"models": modules, "default_connection": "default"}
112
+ config["apps"][app_name] = {
113
+ "models": modules,
114
+ "default_connection": "default",
115
+ }
107
116
 
108
117
  return config
109
118
 
110
- def build_aerich_command(self, app_label: str, db_url: Optional[str] = None) -> AerichCommand:
119
+ def build_aerich_command(
120
+ self, app_label: str, db_url: Optional[str] = None
121
+ ) -> AerichCommand:
111
122
  # Resolve label to flat_label
112
123
  if app_label in self._apps:
113
124
  flat_label = app_label
@@ -128,7 +139,7 @@ class Project:
128
139
  return AerichCommand(
129
140
  tortoise_config=tortoise_cfg,
130
141
  app=flat_label,
131
- location=str(self.migrations_dir)
142
+ location=str(self.migrations_dir),
132
143
  )
133
144
 
134
145
  # --- ORM lifecycle ---
@@ -143,7 +154,9 @@ class Project:
143
154
  await Tortoise.close_connections()
144
155
 
145
156
  # --- Migration helpers ---
146
- async def makemigrations(self, app_label: str, name: str = "auto", db_url: Optional[str] = None) -> None:
157
+ async def makemigrations(
158
+ self, app_label: str, name: str = "auto", db_url: Optional[str] = None
159
+ ) -> None:
147
160
  cmd = self.build_aerich_command(app_label, db_url=db_url)
148
161
  async with cmd as c:
149
162
  await c.init()
@@ -157,7 +170,9 @@ class Project:
157
170
  await c.init_db(safe=True)
158
171
  await c.migrate(name=name)
159
172
 
160
- async def migrate(self, app_label: Optional[str] = None, db_url: Optional[str] = None) -> None:
173
+ async def migrate(
174
+ self, app_label: Optional[str] = None, db_url: Optional[str] = None
175
+ ) -> None:
161
176
  labels: List[str]
162
177
  if app_label:
163
178
  if app_label in self._apps:
@@ -218,30 +233,47 @@ class App:
218
233
  pass
219
234
 
220
235
  def __repr__(self):
221
- out = ""
222
- out += f"App: {self.name}\n"
223
- out += f"Models:\n"
224
- for model in self.models:
225
- out += f" - {model.__name__}\n"
226
- out += "Routes:\n"
227
- for route in (self.routes or []):
228
- out += f" - {route}\n"
229
- return out
236
+ return json.dumps(self.dict(), indent=2)
230
237
 
231
238
  def __str__(self):
232
239
  return self.__repr__()
233
240
 
241
+ def _serialize_route(self, route):
242
+ """Convert APIRoute to JSON-serializable dict."""
243
+ return {
244
+ "path": route.path,
245
+ "name": route.name,
246
+ "methods": list(route.methods),
247
+ "endpoint": route.endpoint.__name__, # just the function name
248
+ "response_model": (
249
+ getattr(route, "response_model", None).__name__
250
+ if getattr(route, "response_model", None)
251
+ else None
252
+ ),
253
+ "tags": getattr(route, "tags", None),
254
+ }
255
+
256
+ def _serialize_router(self):
257
+ return [self._serialize_route(route) for route in self.routes]
258
+
259
+ def dict(self) -> Dict[str, Any]:
260
+ return {
261
+ "models": [m.__name__ for m in self.models],
262
+ "routes": self._serialize_router(),
263
+ }
264
+
234
265
  @property
235
- def models(self) -> List[Model]:
236
- models: List[Model] = []
266
+ def models(self) -> Generator[Model, None, None]:
237
267
  for mod in self.model_modules:
238
268
  models_mod = importlib.import_module(mod)
239
269
  for obj in models_mod.__dict__.values():
240
- if isinstance(obj, type) and getattr(obj, "_meta", None) is not None and obj.__name__ != 'Model':
241
- models.append(obj)
242
- return models
270
+ if (
271
+ isinstance(obj, type)
272
+ and getattr(obj, "_meta", None) is not None
273
+ and obj.__name__ != "Model"
274
+ ):
275
+ yield obj
243
276
 
244
277
  @property
245
278
  def routes(self):
246
279
  return self.router.routes
247
-
@@ -1,4 +1,5 @@
1
1
  from pathlib import Path
2
+
2
3
  from jinja2 import Environment, FileSystemLoader
3
4
 
4
5
  # Base templates directory
@@ -8,14 +9,21 @@ env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
8
9
 
9
10
  def render_template_file(template_path: Path, context: dict, output_path: Path):
10
11
  """Render a single Jinja2 template file to disk."""
11
- template = env.get_template(str(template_path.relative_to(TEMPLATE_DIR)).replace("\\", "/"))
12
+ template = env.get_template(
13
+ str(template_path.relative_to(TEMPLATE_DIR)).replace("\\", "/")
14
+ )
12
15
  content = template.render(**context)
13
16
  output_path.parent.mkdir(exist_ok=True)
14
17
  with open(output_path, "w", encoding="utf-8") as f:
15
18
  f.write(content)
16
19
 
17
20
 
18
- def render_template_dir(template_subdir: str, target_dir: Path, context: dict, subdir_name: str | None = None):
21
+ def render_template_dir(
22
+ template_subdir: str,
23
+ target_dir: Path,
24
+ context: dict,
25
+ subdir_name: str | None = None,
26
+ ):
19
27
  """
20
28
  Recursively render all *.j2 templates from TEMPLATE_DIR/template_subdir into target_dir.
21
29
  If subdir_name is given, files are placed inside target_dir/subdir_name.
@@ -23,14 +31,18 @@ def render_template_dir(template_subdir: str, target_dir: Path, context: dict, s
23
31
  template_dir = TEMPLATE_DIR / template_subdir
24
32
  for root, _, files in template_dir.walk():
25
33
  root_path = Path(root)
26
- rel_root = root_path.relative_to(template_dir) # path relative to template_subdir
34
+ rel_root = root_path.relative_to(
35
+ template_dir
36
+ ) # path relative to template_subdir
27
37
 
28
38
  for f in files:
29
39
  if not f.endswith(".j2"):
30
40
  continue
31
41
 
32
42
  template_rel_path = rel_root / f
33
- output_rel_path = Path(*template_rel_path.parts).with_suffix("") # remove .j2
43
+ output_rel_path = Path(*template_rel_path.parts).with_suffix(
44
+ ""
45
+ ) # remove .j2
34
46
 
35
47
  # optionally wrap in subdir_name
36
48
  if subdir_name:
@@ -54,7 +66,11 @@ def startapp(name: str, project: str):
54
66
  """Create a new app inside a project: templates go into <project_dir>/<name>/"""
55
67
  target_dir = Path(project)
56
68
  target_dir.mkdir(exist_ok=True)
57
- render_template_dir("app", target_dir, {"project_name": target_dir.resolve().name, "app_name": name}, subdir_name=name)
69
+ render_template_dir(
70
+ "app",
71
+ target_dir,
72
+ {"project_name": target_dir.resolve().name, "app_name": name},
73
+ subdir_name=name,
74
+ )
58
75
  print(f"✅ App '{name}' created in project '{target_dir}' successfully.")
59
76
  print(f"🔧 Remember to add '{name}' to your INSTALLED_APPS!")
60
-
@@ -1,6 +1,6 @@
1
1
  from ohmyapi.router import APIRouter
2
2
 
3
- from . import models
3
+ from .models import ...
4
4
 
5
5
  # Expose your app's routes via `router = fastapi.APIRouter`.
6
6
  # Use prefixes wisely to avoid cross-app namespace-collisions.
@@ -1,2 +1 @@
1
1
  from tortoise.exceptions import *
2
-
@@ -1,6 +1,32 @@
1
+ from uuid import UUID
2
+
3
+ from pydantic import GetCoreSchemaHandler
4
+ from pydantic_core import core_schema
1
5
  from tortoise import fields as field
2
- from tortoise.models import Model as TortoiseModel
3
6
  from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator
7
+ from tortoise.models import Model as TortoiseModel
8
+
9
+
10
+ def __uuid_schema_monkey_patch(cls, source_type, handler):
11
+ # Always treat UUID as string schema
12
+ return core_schema.no_info_after_validator_function(
13
+ # Accept UUID or str, always return UUID internally
14
+ lambda v: v if isinstance(v, UUID) else UUID(str(v)),
15
+ core_schema.union_schema(
16
+ [
17
+ core_schema.str_schema(),
18
+ core_schema.is_instance_schema(UUID),
19
+ ]
20
+ ),
21
+ # But when serializing, always str()
22
+ serialization=core_schema.plain_serializer_function_ser_schema(
23
+ str, when_used="always"
24
+ ),
25
+ )
26
+
27
+
28
+ # Monkey-patch UUID
29
+ UUID.__get_pydantic_core_schema__ = classmethod(__uuid_schema_monkey_patch)
4
30
 
5
31
 
6
32
  class ModelMeta(type(TortoiseModel)):
@@ -43,4 +69,3 @@ class Model(TortoiseModel, metaclass=ModelMeta):
43
69
  class Schema:
44
70
  include = None
45
71
  exclude = None
46
-
@@ -1,2 +1 @@
1
1
  from fastapi import APIRouter, Depends, HTTPException
2
-
@@ -1,4 +0,0 @@
1
- from . import models
2
- from . import routes
3
- from . import permissions
4
-
@@ -1,8 +1,8 @@
1
1
  from .routes import (
2
- get_token,
3
2
  get_current_user,
4
- require_authenticated,
3
+ get_token,
5
4
  require_admin,
6
- require_staff,
5
+ require_authenticated,
7
6
  require_group,
7
+ require_staff,
8
8
  )
@@ -1,10 +1,10 @@
1
- from .model import Model, field
2
1
  from tortoise.manager import Manager
3
2
  from tortoise.queryset import QuerySet
4
3
  from tortoise.signals import (
5
- pre_delete,
6
4
  post_delete,
7
- pre_save,
8
5
  post_save,
6
+ pre_delete,
7
+ pre_save,
9
8
  )
10
9
 
10
+ from .model import Model, field