qivo 0.1.1__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.
qivo-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.3
2
+ Name: qivo
3
+ Version: 0.1.1
4
+ Summary: A small Flask layer for dependency injection, authorization, and response serialization.
5
+ Requires-Dist: alembic>=1.20.0
6
+ Requires-Dist: flask>=3.1.3
7
+ Requires-Dist: sqlalchemy>=2.1.1
8
+ Requires-Python: >=3.14
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Qivo
12
+
13
+ Qivo is a small Flask layer for dependency injection, authorization, and response
14
+ serialization. Each app owns its container, serializer, guards, and view-extension
15
+ pipeline; it does not choose or depend on a database.
16
+
17
+ ## CLI
18
+
19
+ Initialize a suggested app layout in the current directory, or choose a target
20
+ directory and Python package name:
21
+
22
+ ```console
23
+ uv run qivo init
24
+ uv run qivo init --name "Mi lista" --path ./service --package service
25
+ ```
26
+
27
+ `qivo init` always asks for a required application name unless `--name` is passed.
28
+ The scaffold contains a minimal Flask todo list with a `Task` model, the four CRUD
29
+ endpoints, `qivo.toml`, and one HTML template at `app/template/app.html`, loaded
30
+ with Flask's normal `render_template` mechanism. Existing files are left untouched.
31
+ Edit the TOML settings to configure the database URL, model modules, model base,
32
+ and migrations directory.
33
+
34
+ ```console
35
+ uv run qivo migrate --message "initial schema"
36
+ uv run qivo migrate:apply
37
+ uv run qivo migrate:revert
38
+ uv run qivo migrate:revert base
39
+ ```
40
+
41
+ Migration commands accept `--config`, `--database-url`, `--model-base`, repeated
42
+ `--models`, and `--migrations-dir` overrides. `migrate` also supports `--empty`;
43
+ `migrate:apply` defaults to `head`, and `migrate:revert` defaults to `-1`.
44
+
45
+ ```python
46
+ from flask import Flask
47
+
48
+ from qivo import Qivo
49
+
50
+ app = Flask(__name__)
51
+ qivo = Qivo(app)
52
+
53
+
54
+ class Greeting:
55
+ def __init__(self):
56
+ self.message = "Hello"
57
+
58
+
59
+ qivo.container.register(Greeting)
60
+
61
+
62
+ @app.route("/greeting", methods=["GET"])
63
+ @qivo.view()
64
+ def greeting(service: Greeting):
65
+ return {"message": service.message}
66
+ ```
67
+
68
+ ## Authorization
69
+
70
+ Implement `Authenticator` against the identity source used by your application,
71
+ then add it to the app's `guards` mapping. Apply authentication or policies per
72
+ route with `auth`, `guard`, and `policies`:
73
+
74
+ ```python
75
+ from qivo.guards import Authenticator, WithAll
76
+
77
+ qivo.guards["web"] = MyAuthenticator()
78
+
79
+
80
+ @app.route("/admin", methods=["GET"])
81
+ @qivo.view(auth=True, policies=[WithAll(["admin:read"])])
82
+ def admin_view():
83
+ return {"ok": True}
84
+ ```
85
+
86
+ ## Extending Views
87
+
88
+ Add a `ViewExtension` to customize view handling without changing `Qivo` or the
89
+ built-in features. Extensions are applied outside-in in registration order. Pass
90
+ extension-specific route settings directly to `qivo.view(...)`:
91
+
92
+ ```python
93
+ from functools import wraps
94
+
95
+
96
+ class AuditExtension:
97
+ def wrap_view(self, app, view_func, *, options):
98
+ @wraps(view_func)
99
+ def wrapper(*args, **kwargs):
100
+ result = view_func(*args, **kwargs)
101
+ app.app.logger.info("audit category=%s", options.get("audit_category"))
102
+ return result
103
+
104
+ return wrapper
105
+
106
+
107
+ qivo.register_view_extension(AuditExtension())
108
+
109
+
110
+ @app.route("/records", methods=["GET"])
111
+ @qivo.view(audit_category="records")
112
+ def records():
113
+ return []
114
+ ```
115
+
116
+ An extension receives the Qivo instance, the next view callable, and an immutable
117
+ mapping of Qivo route options. Register extensions before declaring routes so they
118
+ wrap those routes. Place `@qivo.view(...)` directly below either `@app.route(...)`
119
+ or `@blueprint.route(...)`; Flask handles registration and lifecycle as usual.
120
+
121
+ ## SQLAlchemy
122
+
123
+ Configure the database declaratively in Flask, then attach the SQL extension.
124
+ `SQLEngine` reads the URL, engine options, and session options from `app.config`
125
+ and configures the model base automatically. Each terminal query opens and closes
126
+ its own session:
127
+
128
+ ```python
129
+ from flask import Flask
130
+ from sqlalchemy.orm import Mapped, mapped_column
131
+
132
+ from qivo import Qivo
133
+ from qivo.db.sql import Model
134
+ from qivo.db.sql.extensions import SQLEngine
135
+
136
+ app = Flask(__name__)
137
+ app.config.from_mapping(
138
+ SQLALCHEMY_DATABASE_URI="sqlite:///app.db",
139
+ SQLALCHEMY_ENGINE_OPTIONS={},
140
+ SQLALCHEMY_SESSION_OPTIONS={},
141
+ )
142
+ qivo = Qivo(app)
143
+ db = SQLEngine(app)
144
+
145
+
146
+ class User(Model):
147
+ __tablename__ = "users"
148
+
149
+ id: Mapped[int] = mapped_column(primary_key=True)
150
+ name: Mapped[str]
151
+ is_active: Mapped[bool]
152
+
153
+
154
+ user = User(name="admin", is_active=True)
155
+ user.q.save()
156
+
157
+ user = User.q.get(user.id)
158
+ user.name = "root"
159
+ user.q.save()
160
+
161
+ user.q.delete() # Returns False if the row no longer exists.
162
+
163
+ admin = User.q.filter(name="admin").first()
164
+ active_admins = User.q.where(User.name == "admin").where(
165
+ User.is_active == True
166
+ ).all()
167
+ ```
168
+
169
+ Use eager loading for relationships that must be accessed after a query returns,
170
+ because its session is closed when the operation completes. `SQLEngine` does not
171
+ create tables automatically; use the migration commands for schema changes.
172
+
173
+ ## Migrations
174
+
175
+ `AlembicMigrations` uses `Model.metadata` and creates a standard `migrations/`
176
+ directory on the first revision. Import all model modules before autogenerating
177
+ so Alembic can see their tables:
178
+
179
+ ```python
180
+ from qivo.db.sql import AlembicMigrations
181
+
182
+ migrations = AlembicMigrations(engine)
183
+ migrations.revision("initial schema")
184
+ migrations.upgrade()
185
+ ```
186
+
187
+ The directory and autogeneration options can be customized:
188
+
189
+ ```python
190
+ from qivo.db.sql import AlembicMigrations, MigrationConfig
191
+
192
+ migrations = AlembicMigrations(
193
+ engine,
194
+ config=MigrationConfig(directory="db/migrations", compare_type=True),
195
+ )
196
+ ```
197
+
198
+ Use `migrations.downgrade()` to revert one revision or pass a target such as
199
+ `"base"`; use `migrations.stamp()` to mark a database without applying scripts.
qivo-0.1.1/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # Qivo
2
+
3
+ Qivo is a small Flask layer for dependency injection, authorization, and response
4
+ serialization. Each app owns its container, serializer, guards, and view-extension
5
+ pipeline; it does not choose or depend on a database.
6
+
7
+ ## CLI
8
+
9
+ Initialize a suggested app layout in the current directory, or choose a target
10
+ directory and Python package name:
11
+
12
+ ```console
13
+ uv run qivo init
14
+ uv run qivo init --name "Mi lista" --path ./service --package service
15
+ ```
16
+
17
+ `qivo init` always asks for a required application name unless `--name` is passed.
18
+ The scaffold contains a minimal Flask todo list with a `Task` model, the four CRUD
19
+ endpoints, `qivo.toml`, and one HTML template at `app/template/app.html`, loaded
20
+ with Flask's normal `render_template` mechanism. Existing files are left untouched.
21
+ Edit the TOML settings to configure the database URL, model modules, model base,
22
+ and migrations directory.
23
+
24
+ ```console
25
+ uv run qivo migrate --message "initial schema"
26
+ uv run qivo migrate:apply
27
+ uv run qivo migrate:revert
28
+ uv run qivo migrate:revert base
29
+ ```
30
+
31
+ Migration commands accept `--config`, `--database-url`, `--model-base`, repeated
32
+ `--models`, and `--migrations-dir` overrides. `migrate` also supports `--empty`;
33
+ `migrate:apply` defaults to `head`, and `migrate:revert` defaults to `-1`.
34
+
35
+ ```python
36
+ from flask import Flask
37
+
38
+ from qivo import Qivo
39
+
40
+ app = Flask(__name__)
41
+ qivo = Qivo(app)
42
+
43
+
44
+ class Greeting:
45
+ def __init__(self):
46
+ self.message = "Hello"
47
+
48
+
49
+ qivo.container.register(Greeting)
50
+
51
+
52
+ @app.route("/greeting", methods=["GET"])
53
+ @qivo.view()
54
+ def greeting(service: Greeting):
55
+ return {"message": service.message}
56
+ ```
57
+
58
+ ## Authorization
59
+
60
+ Implement `Authenticator` against the identity source used by your application,
61
+ then add it to the app's `guards` mapping. Apply authentication or policies per
62
+ route with `auth`, `guard`, and `policies`:
63
+
64
+ ```python
65
+ from qivo.guards import Authenticator, WithAll
66
+
67
+ qivo.guards["web"] = MyAuthenticator()
68
+
69
+
70
+ @app.route("/admin", methods=["GET"])
71
+ @qivo.view(auth=True, policies=[WithAll(["admin:read"])])
72
+ def admin_view():
73
+ return {"ok": True}
74
+ ```
75
+
76
+ ## Extending Views
77
+
78
+ Add a `ViewExtension` to customize view handling without changing `Qivo` or the
79
+ built-in features. Extensions are applied outside-in in registration order. Pass
80
+ extension-specific route settings directly to `qivo.view(...)`:
81
+
82
+ ```python
83
+ from functools import wraps
84
+
85
+
86
+ class AuditExtension:
87
+ def wrap_view(self, app, view_func, *, options):
88
+ @wraps(view_func)
89
+ def wrapper(*args, **kwargs):
90
+ result = view_func(*args, **kwargs)
91
+ app.app.logger.info("audit category=%s", options.get("audit_category"))
92
+ return result
93
+
94
+ return wrapper
95
+
96
+
97
+ qivo.register_view_extension(AuditExtension())
98
+
99
+
100
+ @app.route("/records", methods=["GET"])
101
+ @qivo.view(audit_category="records")
102
+ def records():
103
+ return []
104
+ ```
105
+
106
+ An extension receives the Qivo instance, the next view callable, and an immutable
107
+ mapping of Qivo route options. Register extensions before declaring routes so they
108
+ wrap those routes. Place `@qivo.view(...)` directly below either `@app.route(...)`
109
+ or `@blueprint.route(...)`; Flask handles registration and lifecycle as usual.
110
+
111
+ ## SQLAlchemy
112
+
113
+ Configure the database declaratively in Flask, then attach the SQL extension.
114
+ `SQLEngine` reads the URL, engine options, and session options from `app.config`
115
+ and configures the model base automatically. Each terminal query opens and closes
116
+ its own session:
117
+
118
+ ```python
119
+ from flask import Flask
120
+ from sqlalchemy.orm import Mapped, mapped_column
121
+
122
+ from qivo import Qivo
123
+ from qivo.db.sql import Model
124
+ from qivo.db.sql.extensions import SQLEngine
125
+
126
+ app = Flask(__name__)
127
+ app.config.from_mapping(
128
+ SQLALCHEMY_DATABASE_URI="sqlite:///app.db",
129
+ SQLALCHEMY_ENGINE_OPTIONS={},
130
+ SQLALCHEMY_SESSION_OPTIONS={},
131
+ )
132
+ qivo = Qivo(app)
133
+ db = SQLEngine(app)
134
+
135
+
136
+ class User(Model):
137
+ __tablename__ = "users"
138
+
139
+ id: Mapped[int] = mapped_column(primary_key=True)
140
+ name: Mapped[str]
141
+ is_active: Mapped[bool]
142
+
143
+
144
+ user = User(name="admin", is_active=True)
145
+ user.q.save()
146
+
147
+ user = User.q.get(user.id)
148
+ user.name = "root"
149
+ user.q.save()
150
+
151
+ user.q.delete() # Returns False if the row no longer exists.
152
+
153
+ admin = User.q.filter(name="admin").first()
154
+ active_admins = User.q.where(User.name == "admin").where(
155
+ User.is_active == True
156
+ ).all()
157
+ ```
158
+
159
+ Use eager loading for relationships that must be accessed after a query returns,
160
+ because its session is closed when the operation completes. `SQLEngine` does not
161
+ create tables automatically; use the migration commands for schema changes.
162
+
163
+ ## Migrations
164
+
165
+ `AlembicMigrations` uses `Model.metadata` and creates a standard `migrations/`
166
+ directory on the first revision. Import all model modules before autogenerating
167
+ so Alembic can see their tables:
168
+
169
+ ```python
170
+ from qivo.db.sql import AlembicMigrations
171
+
172
+ migrations = AlembicMigrations(engine)
173
+ migrations.revision("initial schema")
174
+ migrations.upgrade()
175
+ ```
176
+
177
+ The directory and autogeneration options can be customized:
178
+
179
+ ```python
180
+ from qivo.db.sql import AlembicMigrations, MigrationConfig
181
+
182
+ migrations = AlembicMigrations(
183
+ engine,
184
+ config=MigrationConfig(directory="db/migrations", compare_type=True),
185
+ )
186
+ ```
187
+
188
+ Use `migrations.downgrade()` to revert one revision or pass a target such as
189
+ `"base"`; use `migrations.stamp()` to mark a database without applying scripts.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "qivo"
3
+ version = "0.1.1"
4
+ description = "A small Flask layer for dependency injection, authorization, and response serialization."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "alembic>=1.20.0",
9
+ "flask>=3.1.3",
10
+ "sqlalchemy>=2.1.1",
11
+ ]
12
+
13
+ [project.scripts]
14
+ qivo = "qivo.cli:cli"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.12.5,<0.13.0"]
18
+ build-backend = "uv_build"
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "qivo"
3
+ version = "0.1.1"
4
+ description = "A small Flask layer for dependency injection, authorization, and response serialization."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "alembic>=1.20.0",
9
+ "flask>=3.1.3",
10
+ "sqlalchemy>=2.1.1",
11
+ ]
12
+
13
+ [project.scripts]
14
+ qivo = "qivo.cli:cli"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.12.5,<0.13.0"]
18
+ build-backend = "uv_build"
@@ -0,0 +1,83 @@
1
+ from collections.abc import Mapping
2
+ from types import MappingProxyType
3
+ from typing import Any, Callable
4
+
5
+ from flask import Flask
6
+
7
+ from qivo.dependencies import DIContainer, DependencyInjectionExtension
8
+ from qivo.extensions import ViewExtension
9
+ from qivo.guards import Authenticator, AuthorizationExtension, Policy
10
+ from qivo.serialization import SerializationExtension, Serializer
11
+
12
+
13
+ class Qivo:
14
+ def __init__(
15
+ self,
16
+ app: Flask | None = None,
17
+ serializer: Serializer | None = None,
18
+ container: DIContainer | None = None,
19
+ guards: Mapping[str, Authenticator] | None = None,
20
+ view_extensions: list[ViewExtension] | None = None,
21
+ ):
22
+ self.app: Flask | None = None
23
+ self.serializer = serializer or Serializer()
24
+ self.container = container or DIContainer()
25
+ self.guards = dict(guards or {})
26
+ self.default_guard = "web"
27
+ self.view_extensions: list[ViewExtension] = [
28
+ AuthorizationExtension(),
29
+ DependencyInjectionExtension(),
30
+ SerializationExtension(),
31
+ ]
32
+ for extension in view_extensions or ():
33
+ self.register_view_extension(extension)
34
+
35
+ if app is not None:
36
+ self.init_app(app)
37
+
38
+ def init_app(self, app: Flask) -> None:
39
+ """Attach this Qivo extension to one Flask application."""
40
+ if self.app is not None and self.app is not app:
41
+ raise RuntimeError("A Qivo instance can be attached to only one Flask app")
42
+
43
+ existing = app.extensions.get("qivo")
44
+ if existing is not None and existing is not self:
45
+ raise RuntimeError("A Qivo extension is already registered on this app")
46
+
47
+ self.app = app
48
+ app.extensions["qivo"] = self
49
+
50
+ def register_view_extension(self, extension: ViewExtension) -> None:
51
+ """Add a view wrapper; extensions run in registration order outside-in."""
52
+ self.view_extensions.append(extension)
53
+
54
+ def view(self, **options: Any):
55
+ """Wrap a view with Qivo features without registering a Flask route."""
56
+ immutable_options = MappingProxyType(dict(options))
57
+
58
+ def decorator(view_func: Callable):
59
+ for extension in reversed(self.view_extensions):
60
+ view_func = extension.wrap_view(
61
+ self,
62
+ view_func,
63
+ options=immutable_options,
64
+ )
65
+ return view_func
66
+
67
+ return decorator
68
+
69
+ def guard(self, name: str | None = None):
70
+ guard_name = name or self.default_guard
71
+ return self.guards[guard_name]
72
+
73
+ def _serialize_view_response(self, rv):
74
+ target = rv
75
+ if isinstance(rv, tuple):
76
+ target = rv[0]
77
+
78
+ serialized = self.serializer.serialize(target)
79
+
80
+ if isinstance(rv, tuple) and len(rv) > 1:
81
+ return (serialized,) + rv[1:]
82
+
83
+ return serialized