zecmf 0.1.0__py3-none-any.whl
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.
- zecmf/__init__.py +7 -0
- zecmf/api/__init__.py +49 -0
- zecmf/app.py +79 -0
- zecmf/auth/__init__.py +19 -0
- zecmf/auth/decorators.py +79 -0
- zecmf/auth/jwt.py +86 -0
- zecmf/cli/__init__.py +5 -0
- zecmf/cli/commands.py +178 -0
- zecmf/config/__init__.py +60 -0
- zecmf/config/base.py +184 -0
- zecmf/constants.py +39 -0
- zecmf/extensions/__init__.py +1 -0
- zecmf/extensions/database.py +22 -0
- zecmf/extensions/queue.py +37 -0
- zecmf/migrations/README.template +3 -0
- zecmf/migrations/__init__.py +74 -0
- zecmf/migrations/alembic.ini.template +74 -0
- zecmf/migrations/env.py.template +97 -0
- zecmf/migrations/script.py.mako +24 -0
- zecmf/testing/__init__.py +3 -0
- zecmf/testing/config.py +9 -0
- zecmf/testing/constants.py +16 -0
- zecmf-0.1.0.dist-info/METADATA +43 -0
- zecmf-0.1.0.dist-info/RECORD +26 -0
- zecmf-0.1.0.dist-info/WHEEL +5 -0
- zecmf-0.1.0.dist-info/top_level.txt +1 -0
zecmf/__init__.py
ADDED
zecmf/api/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""API module for Flask-RESTX integration."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from flask import Blueprint, Flask
|
|
6
|
+
from flask_restx import Api
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def init_api(
|
|
10
|
+
app: Flask,
|
|
11
|
+
) -> Api:
|
|
12
|
+
"""Initialize the API on the Flask app.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
app: The Flask application
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
The configured API instance
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
# Get API configuration from app.config with defaults
|
|
22
|
+
prefix = app.config.get("API_PREFIX", "/api")
|
|
23
|
+
title = app.config.get("API_TITLE", "API")
|
|
24
|
+
version = app.config.get("API_VERSION", "1.0.0")
|
|
25
|
+
description = app.config.get("API_DESCRIPTION", "...")
|
|
26
|
+
|
|
27
|
+
# Create API blueprint with prefix
|
|
28
|
+
blueprint = Blueprint("api", __name__, url_prefix=prefix)
|
|
29
|
+
|
|
30
|
+
# Configure API with provided settings
|
|
31
|
+
api_config: dict[str, Any] = {
|
|
32
|
+
"doc": False,
|
|
33
|
+
"authorizations": {
|
|
34
|
+
"Bearer": {"type": "apiKey", "in": "header", "name": "Authorization"}
|
|
35
|
+
},
|
|
36
|
+
"security": "Bearer",
|
|
37
|
+
"title": title,
|
|
38
|
+
"version": version,
|
|
39
|
+
"description": description,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Create and register API
|
|
43
|
+
api = Api(blueprint, **api_config)
|
|
44
|
+
app.register_blueprint(blueprint)
|
|
45
|
+
|
|
46
|
+
return api
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["init_api"]
|
zecmf/app.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Application factory for creating Flask microservice applications with standardized configuration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from flask import Flask
|
|
7
|
+
from flask_restx import Namespace
|
|
8
|
+
|
|
9
|
+
from zecmf.api import init_api
|
|
10
|
+
from zecmf.auth import init_jwt
|
|
11
|
+
from zecmf.cli import register_commands
|
|
12
|
+
from zecmf.config import BaseConfig, get_config
|
|
13
|
+
from zecmf.constants import Config as ConfigConstants
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_app(
|
|
19
|
+
config_name: str,
|
|
20
|
+
api_namespaces: list[tuple[Namespace, str]],
|
|
21
|
+
app_config_module: str = ConfigConstants.APP_CONFIG_MODULE,
|
|
22
|
+
) -> Flask:
|
|
23
|
+
"""Create and configure a Flask microservice application.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config_name: The name of the configuration to use (development, production, etc).
|
|
27
|
+
api_namespaces: List of namespaces to register with the API.
|
|
28
|
+
app_config_module: The module path for the app-specific configuration.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A configured Flask application.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
logger.debug(f"Creating app with config_name: {config_name}")
|
|
35
|
+
|
|
36
|
+
if not config_name:
|
|
37
|
+
logger.warning(
|
|
38
|
+
"No config_name provided. Trying to load from environment variable."
|
|
39
|
+
)
|
|
40
|
+
config_name = os.getenv("FLASK_ENV", "development")
|
|
41
|
+
|
|
42
|
+
app_config = _resolve_and_validate_config(config_name, app_config_module)
|
|
43
|
+
app = _initialize_flask_app(app_config)
|
|
44
|
+
_setup_auth(app)
|
|
45
|
+
_setup_api(app, api_namespaces)
|
|
46
|
+
register_commands(app)
|
|
47
|
+
return app
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_and_validate_config(
|
|
51
|
+
config_name: str, app_config_module: str
|
|
52
|
+
) -> BaseConfig:
|
|
53
|
+
"""Resolve and instantiate the configuration class."""
|
|
54
|
+
config_class = get_config(config_name, app_config_module)
|
|
55
|
+
logger.debug(f"Resolved config class: {config_class.__name__}")
|
|
56
|
+
config_instance = config_class() # Validation on instantiation
|
|
57
|
+
return config_instance
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _initialize_flask_app(app_config: BaseConfig) -> Flask:
|
|
61
|
+
"""Create and configure the Flask app instance."""
|
|
62
|
+
app = Flask("app")
|
|
63
|
+
app.config.from_object(app_config)
|
|
64
|
+
return app
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _setup_auth(app: Flask) -> None:
|
|
68
|
+
"""Initialize JWT authentication."""
|
|
69
|
+
init_jwt(app)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _setup_api(
|
|
73
|
+
app: Flask,
|
|
74
|
+
api_namespaces: list[tuple[Namespace, str]],
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Configure API and register namespaces."""
|
|
77
|
+
api = init_api(app)
|
|
78
|
+
for namespace, path in api_namespaces:
|
|
79
|
+
api.add_namespace(namespace, path=path)
|
zecmf/auth/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Authentication module for micro-framework."""
|
|
2
|
+
|
|
3
|
+
from zecmf.auth.decorators import (
|
|
4
|
+
public_endpoint,
|
|
5
|
+
require_admin_role,
|
|
6
|
+
require_agent_role,
|
|
7
|
+
require_role,
|
|
8
|
+
require_user_role,
|
|
9
|
+
)
|
|
10
|
+
from zecmf.auth.jwt import init_jwt
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"init_jwt",
|
|
14
|
+
"public_endpoint",
|
|
15
|
+
"require_admin_role",
|
|
16
|
+
"require_agent_role",
|
|
17
|
+
"require_role",
|
|
18
|
+
"require_user_role",
|
|
19
|
+
]
|
zecmf/auth/decorators.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Authentication decorators for role-based access control."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Any, TypeVar, cast
|
|
6
|
+
|
|
7
|
+
from flask import g
|
|
8
|
+
from werkzeug.exceptions import Forbidden
|
|
9
|
+
|
|
10
|
+
# Type variable for generic functions
|
|
11
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DecoratorFactory:
|
|
15
|
+
"""Factory to create decorators only when needed in a request context."""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def require_role(role: str) -> Callable[[F], F]:
|
|
19
|
+
"""Create a decorator to require a specific role."""
|
|
20
|
+
|
|
21
|
+
def decorator(f: F) -> F:
|
|
22
|
+
@wraps(f)
|
|
23
|
+
def wrapper(*args: object, **kwargs: object) -> object:
|
|
24
|
+
# Check if user has the required role
|
|
25
|
+
if not hasattr(g, "user_roles") or role not in g.user_roles:
|
|
26
|
+
raise Forbidden(f"Requires role: {role}")
|
|
27
|
+
|
|
28
|
+
return f(*args, **kwargs)
|
|
29
|
+
|
|
30
|
+
return cast("F", wrapper)
|
|
31
|
+
|
|
32
|
+
return decorator
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def public_endpoint() -> Callable[[F], F]:
|
|
36
|
+
"""Create a decorator to mark an endpoint as public.
|
|
37
|
+
|
|
38
|
+
This decorator can be applied to:
|
|
39
|
+
- Regular Flask routes (function-based views)
|
|
40
|
+
- Specific HTTP methods in Flask-RESTX Resources (apply to method)
|
|
41
|
+
|
|
42
|
+
Note: For Flask-RESTX Resource classes, do not apply this decorator directly to
|
|
43
|
+
the class. Instead, apply it to individual methods.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def decorator(f: F) -> F:
|
|
47
|
+
# Mark the original function as public
|
|
48
|
+
f.is_public = True # type: ignore
|
|
49
|
+
|
|
50
|
+
@wraps(f)
|
|
51
|
+
def wrapper(*args: object, **kwargs: object) -> object:
|
|
52
|
+
return f(*args, **kwargs)
|
|
53
|
+
|
|
54
|
+
# Mark the wrapper function as public
|
|
55
|
+
wrapper.is_public = True # type: ignore
|
|
56
|
+
|
|
57
|
+
return cast("F", wrapper)
|
|
58
|
+
|
|
59
|
+
return decorator
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Create decorator factory instance
|
|
63
|
+
decorator_factory = DecoratorFactory()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def require_role(role: str) -> Callable[[F], F]:
|
|
67
|
+
"""Require a specific role for an endpoint."""
|
|
68
|
+
return decorator_factory.require_role(role)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def public_endpoint() -> Callable[[F], F]:
|
|
72
|
+
"""Mark an endpoint as public, exempt from authentication requirements."""
|
|
73
|
+
return decorator_factory.public_endpoint()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Pre-defined role decorators for convenience
|
|
77
|
+
require_admin_role = require_role("admin")
|
|
78
|
+
require_user_role = require_role("user")
|
|
79
|
+
require_agent_role = require_role("agent")
|
zecmf/auth/jwt.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""JWT authentication module for micro-framework."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from flask import Flask, Response, current_app, g, request
|
|
7
|
+
from flask_jwt_extended import (
|
|
8
|
+
JWTManager,
|
|
9
|
+
get_jwt,
|
|
10
|
+
get_jwt_identity,
|
|
11
|
+
verify_jwt_in_request,
|
|
12
|
+
)
|
|
13
|
+
from werkzeug.exceptions import Unauthorized
|
|
14
|
+
|
|
15
|
+
# Type variable for generic functions
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
jwt = JWTManager()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_public_endpoint(endpoint_func: Callable[..., Any]) -> bool:
|
|
23
|
+
"""Check if an endpoint is marked as public."""
|
|
24
|
+
# Check if it's a class-based view (like Flask-RESTX resources)
|
|
25
|
+
if hasattr(endpoint_func, "view_class"):
|
|
26
|
+
view_class = endpoint_func.view_class
|
|
27
|
+
|
|
28
|
+
# Check class for the is_public attribute
|
|
29
|
+
if hasattr(view_class, "is_public") and view_class.is_public:
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
# Check the specific method for the is_public attribute
|
|
33
|
+
method = request.method.lower()
|
|
34
|
+
if hasattr(view_class, method):
|
|
35
|
+
handler = getattr(view_class, method)
|
|
36
|
+
if hasattr(handler, "is_public") and handler.is_public:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
# For regular function-based views
|
|
40
|
+
elif hasattr(endpoint_func, "is_public") and endpoint_func.is_public:
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _authenticate_request() -> None:
|
|
47
|
+
"""Authenticate the current request using JWT."""
|
|
48
|
+
try:
|
|
49
|
+
verify_jwt_in_request()
|
|
50
|
+
|
|
51
|
+
# Get JWT claims
|
|
52
|
+
claims = get_jwt()
|
|
53
|
+
user_roles = claims.get("roles", [])
|
|
54
|
+
|
|
55
|
+
# Store user info in flask g object for use in the request
|
|
56
|
+
g.user_id = get_jwt_identity()
|
|
57
|
+
g.user_roles = user_roles
|
|
58
|
+
except Exception as err:
|
|
59
|
+
raise Unauthorized("Authentication required") from err
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def init_jwt(app: Flask) -> None:
|
|
63
|
+
"""Initialize JWT manager with the Flask app."""
|
|
64
|
+
jwt.init_app(app)
|
|
65
|
+
|
|
66
|
+
# Register before_request handler to enforce authentication by default
|
|
67
|
+
@app.before_request
|
|
68
|
+
def enforce_authentication() -> Response | None:
|
|
69
|
+
"""Enforce authentication for each request unless marked as public."""
|
|
70
|
+
# Skip OPTIONS requests for CORS support
|
|
71
|
+
if request.method == "OPTIONS":
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Allow access to swagger.json only in debug mode
|
|
75
|
+
if app.debug and request.path.endswith("/swagger.json"):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
# Check if the endpoint is public
|
|
79
|
+
if request.endpoint is not None:
|
|
80
|
+
endpoint_func = current_app.view_functions.get(request.endpoint)
|
|
81
|
+
if endpoint_func is not None and _check_public_endpoint(endpoint_func):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
# If we're here, authentication is required
|
|
85
|
+
_authenticate_request()
|
|
86
|
+
return None
|
zecmf/cli/__init__.py
ADDED
zecmf/cli/commands.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Common CLI commands for Flask applications."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from flask import Flask, current_app, has_app_context
|
|
8
|
+
from flask.cli import with_appcontext
|
|
9
|
+
from flask_migrate import init, migrate, upgrade
|
|
10
|
+
from sqlalchemy import inspect, text
|
|
11
|
+
from sqlalchemy.exc import ProgrammingError
|
|
12
|
+
|
|
13
|
+
from zecmf.extensions.database import db
|
|
14
|
+
from zecmf.migrations import setup_migrations
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def setup_db_impl() -> None:
|
|
18
|
+
"""Implement database setup logic."""
|
|
19
|
+
click.echo("Setting up database...")
|
|
20
|
+
|
|
21
|
+
# Get database connection
|
|
22
|
+
inspector = inspect(db.engine)
|
|
23
|
+
|
|
24
|
+
# Check if tables already exist
|
|
25
|
+
existing_tables = inspector.get_table_names()
|
|
26
|
+
|
|
27
|
+
# Check if migrations directory exists
|
|
28
|
+
app_root = current_app.root_path
|
|
29
|
+
migrations_dir = os.path.join(os.path.dirname(app_root), "migrations")
|
|
30
|
+
migrations_dir_exists = os.path.exists(migrations_dir)
|
|
31
|
+
migrations_versions_dir = os.path.join(migrations_dir, "versions")
|
|
32
|
+
has_migration_versions = (
|
|
33
|
+
os.path.exists(migrations_versions_dir)
|
|
34
|
+
and len(os.listdir(migrations_versions_dir)) > 0
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Initialize migrations directory if it doesn't exist
|
|
38
|
+
if not migrations_dir_exists:
|
|
39
|
+
click.echo("Initializing migrations directory...")
|
|
40
|
+
init()
|
|
41
|
+
# Create initial migration if needed
|
|
42
|
+
if not existing_tables:
|
|
43
|
+
click.echo("Creating initial migration...")
|
|
44
|
+
migrate(message="Initial migration")
|
|
45
|
+
|
|
46
|
+
# Try to apply migrations
|
|
47
|
+
try:
|
|
48
|
+
click.echo("Applying migrations...")
|
|
49
|
+
upgrade()
|
|
50
|
+
click.echo("Migrations applied successfully!")
|
|
51
|
+
except ProgrammingError as e:
|
|
52
|
+
click.echo(f"Migration error: {e!s}")
|
|
53
|
+
|
|
54
|
+
# If migrations exist but failed, and we have no tables, create tables directly
|
|
55
|
+
if has_migration_versions and not existing_tables:
|
|
56
|
+
click.echo("Falling back to direct table creation...")
|
|
57
|
+
db.create_all()
|
|
58
|
+
click.echo("Tables created directly.")
|
|
59
|
+
else:
|
|
60
|
+
click.echo(
|
|
61
|
+
"Could not apply migrations. Please check your database configuration."
|
|
62
|
+
)
|
|
63
|
+
raise
|
|
64
|
+
|
|
65
|
+
click.echo("Database setup complete!")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def health_check_impl() -> None:
|
|
69
|
+
"""Implement health check logic."""
|
|
70
|
+
click.echo("Checking application health...")
|
|
71
|
+
|
|
72
|
+
# Check database connection
|
|
73
|
+
try:
|
|
74
|
+
# Use text() to wrap SQL string for proper typing
|
|
75
|
+
db.session.execute(text("SELECT 1"))
|
|
76
|
+
click.echo("Database connection: OK")
|
|
77
|
+
except Exception as e:
|
|
78
|
+
click.echo(f"Database connection: FAILED ({e!s})")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
click.echo("All systems operational!")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def init_migrations_impl(models_module: list[str]) -> None:
|
|
85
|
+
"""Implement migrations initialization logic.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
models_module: Modules containing models to import (e.g., app.models.user)
|
|
89
|
+
|
|
90
|
+
"""
|
|
91
|
+
# Determine base directory for migrations (app root if available, else cwd)
|
|
92
|
+
if has_app_context():
|
|
93
|
+
base_dir = os.path.dirname(current_app.root_path)
|
|
94
|
+
else:
|
|
95
|
+
base_dir = os.path.dirname(os.getcwd())
|
|
96
|
+
migrations_dir = os.path.join(base_dir, "migrations")
|
|
97
|
+
|
|
98
|
+
# Generate import statements
|
|
99
|
+
model_imports = []
|
|
100
|
+
for module_name in models_module:
|
|
101
|
+
try:
|
|
102
|
+
module = importlib.import_module(module_name)
|
|
103
|
+
# Get model classes from the module
|
|
104
|
+
for attr_name in dir(module):
|
|
105
|
+
if attr_name.startswith("_"):
|
|
106
|
+
continue
|
|
107
|
+
attr = getattr(module, attr_name)
|
|
108
|
+
if hasattr(attr, "__tablename__"):
|
|
109
|
+
model_imports.append(f"from {module_name} import {attr_name}")
|
|
110
|
+
except ImportError:
|
|
111
|
+
click.echo(f"Warning: Could not import module {module_name}")
|
|
112
|
+
|
|
113
|
+
# Add import statements for the models
|
|
114
|
+
click.echo(f"Setting up migrations in {migrations_dir}")
|
|
115
|
+
setup_migrations(
|
|
116
|
+
destination_dir=migrations_dir,
|
|
117
|
+
models_import_statements=model_imports if model_imports else None,
|
|
118
|
+
)
|
|
119
|
+
click.echo("Migrations directory initialized successfully.")
|
|
120
|
+
click.echo("\nNext steps:")
|
|
121
|
+
click.echo("1. Review the generated files")
|
|
122
|
+
click.echo("2. Run 'flask db init' to initialize Alembic")
|
|
123
|
+
click.echo(
|
|
124
|
+
"3. Run 'flask db migrate -m \"Initial migration\"' to create your first migration"
|
|
125
|
+
)
|
|
126
|
+
click.echo("4. Run 'flask db upgrade' to apply the migration")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@click.command("setup-db")
|
|
130
|
+
@with_appcontext
|
|
131
|
+
def setup_db() -> None:
|
|
132
|
+
"""Set up database migrations and apply them.
|
|
133
|
+
|
|
134
|
+
This command:
|
|
135
|
+
1. Initializes the migrations directory if it doesn't exist
|
|
136
|
+
2. Applies all existing migrations to a fresh database
|
|
137
|
+
3. Creates tables directly if migration fails
|
|
138
|
+
"""
|
|
139
|
+
setup_db_impl()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@click.command("health-check")
|
|
143
|
+
@with_appcontext
|
|
144
|
+
def health_check() -> None:
|
|
145
|
+
"""Check the health of the application and its dependencies."""
|
|
146
|
+
health_check_impl()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@click.command("init-migrations")
|
|
150
|
+
@click.option(
|
|
151
|
+
"--models-module",
|
|
152
|
+
"-m",
|
|
153
|
+
multiple=True,
|
|
154
|
+
help="Modules containing models to import (format: app.models.user)",
|
|
155
|
+
)
|
|
156
|
+
def init_migrations(models_module: list[str]) -> None:
|
|
157
|
+
"""Initialize migrations directory with templates from the framework.
|
|
158
|
+
|
|
159
|
+
This creates a migrations directory with the standard Alembic files,
|
|
160
|
+
configured to work with the application's models.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
models_module: Modules containing models to import (e.g., app.models.user)
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
init_migrations_impl(models_module)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def register_commands(app: Flask) -> None:
|
|
170
|
+
"""Register custom Flask CLI commands.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
app: The Flask application.
|
|
174
|
+
|
|
175
|
+
"""
|
|
176
|
+
app.cli.add_command(setup_db)
|
|
177
|
+
app.cli.add_command(health_check)
|
|
178
|
+
app.cli.add_command(init_migrations)
|
zecmf/config/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Configuration module for micro-framework."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from zecmf.config.base import (
|
|
7
|
+
BaseConfig,
|
|
8
|
+
BaseDevelopmentConfig,
|
|
9
|
+
BaseProductionConfig,
|
|
10
|
+
BaseTestingConfig,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_config(config_name: str, app_config_module: str) -> type[BaseConfig]:
|
|
17
|
+
"""Get the configuration class by name, with optional app-specific overrides.
|
|
18
|
+
|
|
19
|
+
This function allows application-specific configurations to extend the
|
|
20
|
+
framework's base configurations. It will look for a class with the same name
|
|
21
|
+
as the requested config_name in the app config module and return it if found.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
config_name: The name of the configuration to get ("development", "testing", etc.).
|
|
25
|
+
app_config_module: The module path for the app-specific configuration.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The appropriate configuration class, with app overrides if available.
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
app_config = importlib.import_module(app_config_module)
|
|
33
|
+
app_config_class_name = f"{config_name.capitalize()}Config"
|
|
34
|
+
if not hasattr(app_config, app_config_class_name):
|
|
35
|
+
raise AttributeError(
|
|
36
|
+
f"Config class {app_config_class_name} not found in {app_config_module}."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
app_config_class = getattr(app_config, app_config_class_name)
|
|
40
|
+
if not issubclass(app_config_class, BaseConfig):
|
|
41
|
+
raise TypeError(
|
|
42
|
+
f"App config class {app_config_class_name} is not a subclass of BaseConfig."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
except ImportError as e:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
f"Failed to import app config module '{app_config_module}': {e}"
|
|
48
|
+
) from e
|
|
49
|
+
else:
|
|
50
|
+
logger.debug(f"Using app config class: {app_config_class.__name__}")
|
|
51
|
+
return app_config_class
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"BaseConfig",
|
|
56
|
+
"BaseDevelopmentConfig",
|
|
57
|
+
"BaseProductionConfig",
|
|
58
|
+
"BaseTestingConfig",
|
|
59
|
+
"get_config",
|
|
60
|
+
]
|
zecmf/config/base.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Base configuration classes for the application."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseConfig:
|
|
12
|
+
"""Base configuration class with shared settings for all applications.
|
|
13
|
+
|
|
14
|
+
This contains all common settings that are shared across all applications.
|
|
15
|
+
Applications should only need to define app-specific settings or override
|
|
16
|
+
settings that differ from these defaults.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# Flask settings
|
|
20
|
+
SECRET_KEY: str | None = os.getenv(
|
|
21
|
+
"SECRET_KEY"
|
|
22
|
+
) # Required in production, default in dev/test
|
|
23
|
+
DEBUG: bool = False
|
|
24
|
+
TESTING: bool = False
|
|
25
|
+
|
|
26
|
+
# Database settings
|
|
27
|
+
SQLALCHEMY_DATABASE_URI: str | None = os.getenv("DATABASE_URI")
|
|
28
|
+
SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
|
|
29
|
+
SQLALCHEMY_ENGINE_OPTIONS: ClassVar[dict[str, Any]] = {
|
|
30
|
+
"pool_recycle": 3600,
|
|
31
|
+
"pool_pre_ping": True,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# API settings
|
|
35
|
+
API_TITLE: str = "API"
|
|
36
|
+
API_VERSION: str = "1.0"
|
|
37
|
+
API_DESCRIPTION: str = "REST API Service"
|
|
38
|
+
API_PREFIX: str = "/api/v1"
|
|
39
|
+
API_VERSION_HEADER: bool = True
|
|
40
|
+
|
|
41
|
+
# File upload settings
|
|
42
|
+
MAX_CONTENT_LENGTH: int = 1024 * 1024 # 1MB
|
|
43
|
+
|
|
44
|
+
# JWT settings - supporting both public key (RS256) and secret key (HS256) methods
|
|
45
|
+
JWT_PUBLIC_KEY: str | None = os.getenv("JWT_PUBLIC_KEY")
|
|
46
|
+
JWT_PUBLIC_KEY_PATH: str | None = os.getenv("JWT_PUBLIC_KEY_PATH")
|
|
47
|
+
JWT_SECRET_KEY: str | None = os.getenv("JWT_SECRET_KEY")
|
|
48
|
+
JWT_ALGORITHM: str = "RS256"
|
|
49
|
+
JWT_TOKEN_LOCATION: ClassVar[list[str]] = ["headers"]
|
|
50
|
+
JWT_HEADER_NAME: str = "Authorization"
|
|
51
|
+
JWT_HEADER_TYPE: str = "Bearer"
|
|
52
|
+
JWT_ACCESS_TOKEN_EXPIRES: int = 30 * 60 # seconds
|
|
53
|
+
JWT_REFRESH_TOKEN_EXPIRES: int = 30 * 24 * 60 * 60 # seconds
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
"""Initialize configuration with validation.
|
|
57
|
+
|
|
58
|
+
This validates the configuration based on the selected algorithm:
|
|
59
|
+
- For RS256: requires either JWT_PUBLIC_KEY or JWT_PUBLIC_KEY_PATH
|
|
60
|
+
- For HS256: requires JWT_SECRET_KEY
|
|
61
|
+
"""
|
|
62
|
+
# Skip validation for testing
|
|
63
|
+
if getattr(self, "SKIP_VALIDATION", False):
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# If SECRET_KEY can be None when DEBUG is True, but is required in production
|
|
67
|
+
if not self.DEBUG and not self.SECRET_KEY:
|
|
68
|
+
raise ValueError("SECRET_KEY must be set in production environments.")
|
|
69
|
+
|
|
70
|
+
# For RS256 (asymmetric) authentication
|
|
71
|
+
if self.JWT_ALGORITHM == "RS256":
|
|
72
|
+
self._validate_rs256_config()
|
|
73
|
+
# For HS256 (symmetric) authentication
|
|
74
|
+
elif self.JWT_ALGORITHM == "HS256":
|
|
75
|
+
self._validate_hs256_config()
|
|
76
|
+
|
|
77
|
+
def _validate_rs256_config(self) -> None:
|
|
78
|
+
"""Validate RS256 configuration settings."""
|
|
79
|
+
if self.JWT_PUBLIC_KEY and self.JWT_PUBLIC_KEY_PATH:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"Both JWT_PUBLIC_KEY and JWT_PUBLIC_KEY_PATH are set. "
|
|
82
|
+
"Please provide only one of them."
|
|
83
|
+
)
|
|
84
|
+
# Allow no keys for development but not production
|
|
85
|
+
elif not self.JWT_PUBLIC_KEY and not self.JWT_PUBLIC_KEY_PATH:
|
|
86
|
+
if not self.DEBUG:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"Either JWT_PUBLIC_KEY or JWT_PUBLIC_KEY_PATH must be set "
|
|
89
|
+
"in production when using RS256."
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
# Fall back to HS256 in development when no RS256 keys available
|
|
93
|
+
logger.warning(
|
|
94
|
+
"No RS256 keys available. Falling back to HS256 in development."
|
|
95
|
+
)
|
|
96
|
+
self.JWT_ALGORITHM = "HS256"
|
|
97
|
+
if not self.JWT_SECRET_KEY:
|
|
98
|
+
self.JWT_SECRET_KEY = self.SECRET_KEY
|
|
99
|
+
elif self.JWT_PUBLIC_KEY_PATH and not self.JWT_PUBLIC_KEY:
|
|
100
|
+
path = Path(self.JWT_PUBLIC_KEY_PATH)
|
|
101
|
+
if path.exists():
|
|
102
|
+
with open(path, encoding="utf-8") as f:
|
|
103
|
+
self.JWT_PUBLIC_KEY = f.read()
|
|
104
|
+
elif not self.DEBUG:
|
|
105
|
+
raise ValueError(f"JWT_PUBLIC_KEY_PATH '{path}' does not exist.")
|
|
106
|
+
else:
|
|
107
|
+
# Fall back to HS256 in development when file not found
|
|
108
|
+
logger.warning(
|
|
109
|
+
f"JWT_PUBLIC_KEY_PATH '{path}' not found. Falling back to HS256 in development."
|
|
110
|
+
)
|
|
111
|
+
self.JWT_ALGORITHM = "HS256"
|
|
112
|
+
if not self.JWT_SECRET_KEY:
|
|
113
|
+
self.JWT_SECRET_KEY = self.SECRET_KEY
|
|
114
|
+
|
|
115
|
+
def _validate_hs256_config(self) -> None:
|
|
116
|
+
"""Validate HS256 configuration settings."""
|
|
117
|
+
# Use SECRET_KEY as fallback for JWT_SECRET_KEY if not set
|
|
118
|
+
if not self.JWT_SECRET_KEY and self.SECRET_KEY:
|
|
119
|
+
self.JWT_SECRET_KEY = self.SECRET_KEY
|
|
120
|
+
elif not self.JWT_SECRET_KEY and not self.DEBUG:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"JWT_SECRET_KEY must be set in production when using HS256 algorithm."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class BaseDevelopmentConfig(BaseConfig):
|
|
127
|
+
"""Development configuration for local development environments.
|
|
128
|
+
|
|
129
|
+
Provides defaults that make development easier with minimal setup.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
# Enable debugging features
|
|
133
|
+
DEBUG: bool = True
|
|
134
|
+
|
|
135
|
+
# Default database is SQLite for easy development
|
|
136
|
+
SQLALCHEMY_DATABASE_URI: str = os.getenv("DATABASE_URI", "sqlite:///dev.sqlite3")
|
|
137
|
+
|
|
138
|
+
# Default secret key for development (DO NOT use in production)
|
|
139
|
+
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-key-not-for-production")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class BaseProductionConfig(BaseConfig):
|
|
143
|
+
"""Production configuration for deployment.
|
|
144
|
+
|
|
145
|
+
Prioritizes security and requires proper environment configuration.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
# Disable debugging features in production
|
|
149
|
+
DEBUG: bool = False
|
|
150
|
+
TESTING: bool = False
|
|
151
|
+
|
|
152
|
+
# These need to be explicitly set from environment in production
|
|
153
|
+
# SECRET_KEY = os.getenv("SECRET_KEY") # This is intentionally not set to force proper configuration
|
|
154
|
+
# SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URI") # This is intentionally not set to force proper configuration
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class BaseTestingConfig(BaseConfig):
|
|
158
|
+
"""Testing configuration for unit and integration tests.
|
|
159
|
+
|
|
160
|
+
Provides fast, isolated test environment with in-memory database.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
# Enable testing features
|
|
164
|
+
TESTING: bool = True
|
|
165
|
+
DEBUG: bool = False
|
|
166
|
+
|
|
167
|
+
# Use in-memory SQLite for speed
|
|
168
|
+
# Match the BaseConfig type for compatibility
|
|
169
|
+
SQLALCHEMY_DATABASE_URI: str | None = "sqlite:///:memory:"
|
|
170
|
+
|
|
171
|
+
# Fixed secret key for test reproducibility
|
|
172
|
+
# Match the BaseConfig type for compatibility
|
|
173
|
+
SECRET_KEY: str | None = "testing-secret-key"
|
|
174
|
+
|
|
175
|
+
# HS256 is easier for testing
|
|
176
|
+
JWT_ALGORITHM: str = "HS256"
|
|
177
|
+
# Match the BaseConfig type for compatibility
|
|
178
|
+
JWT_SECRET_KEY: str | None = "testing-secret-key"
|
|
179
|
+
|
|
180
|
+
# Skip validation in tests
|
|
181
|
+
SKIP_VALIDATION: bool = True
|
|
182
|
+
|
|
183
|
+
# Shortened token expiration for faster testing
|
|
184
|
+
JWT_ACCESS_TOKEN_EXPIRES: int = 60 # 1 minute
|
zecmf/constants.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Constants shared across microservices."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# HTTP Status Codes
|
|
5
|
+
class HTTPStatus:
|
|
6
|
+
"""HTTP status codes for API responses."""
|
|
7
|
+
|
|
8
|
+
# Success
|
|
9
|
+
OK = 200
|
|
10
|
+
CREATED = 201
|
|
11
|
+
ACCEPTED = 202
|
|
12
|
+
NO_CONTENT = 204
|
|
13
|
+
|
|
14
|
+
# Client Errors
|
|
15
|
+
BAD_REQUEST = 400
|
|
16
|
+
UNAUTHORIZED = 401
|
|
17
|
+
PAYMENT_REQUIRED = 402
|
|
18
|
+
FORBIDDEN = 403
|
|
19
|
+
NOT_FOUND = 404
|
|
20
|
+
METHOD_NOT_ALLOWED = 405
|
|
21
|
+
CONFLICT = 409
|
|
22
|
+
GONE = 410
|
|
23
|
+
UNPROCESSABLE_ENTITY = 422
|
|
24
|
+
TOO_MANY_REQUESTS = 429
|
|
25
|
+
|
|
26
|
+
# Server Errors
|
|
27
|
+
INTERNAL_SERVER_ERROR = 500
|
|
28
|
+
NOT_IMPLEMENTED = 501
|
|
29
|
+
BAD_GATEWAY = 502
|
|
30
|
+
SERVICE_UNAVAILABLE = 503
|
|
31
|
+
GATEWAY_TIMEOUT = 504
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Common configuration constants
|
|
35
|
+
class Config:
|
|
36
|
+
"""Configuration constants used across services."""
|
|
37
|
+
|
|
38
|
+
# App config module path
|
|
39
|
+
APP_CONFIG_MODULE = "app.config"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Extensions package for Flask application."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Database extension module.
|
|
2
|
+
|
|
3
|
+
Sets up SQLAlchemy and Flask-Migrate for database operations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from flask import Flask
|
|
7
|
+
from flask_migrate import Migrate
|
|
8
|
+
from flask_sqlalchemy import SQLAlchemy
|
|
9
|
+
|
|
10
|
+
db = SQLAlchemy()
|
|
11
|
+
migrate = Migrate()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def init_app(app: Flask) -> None:
|
|
15
|
+
"""Initialize the database extension.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
app: The Flask application.
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
db.init_app(app)
|
|
22
|
+
migrate.init_app(app, db)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Queue extension module.
|
|
2
|
+
|
|
3
|
+
Sets up Celery for asynchronous task processing with Flask integration.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from celery import Celery, Task
|
|
7
|
+
from flask import Flask
|
|
8
|
+
|
|
9
|
+
celery = Celery()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def init_app(app: Flask) -> None:
|
|
13
|
+
"""Initialize Celery with the Flask app."""
|
|
14
|
+
# Get configuration from app config with defaults
|
|
15
|
+
broker_url = app.config.get("CELERY_BROKER_URL", "memory://")
|
|
16
|
+
result_backend = app.config.get("CELERY_RESULT_BACKEND", "cache")
|
|
17
|
+
|
|
18
|
+
celery.conf.update(
|
|
19
|
+
broker_url=broker_url,
|
|
20
|
+
result_backend=result_backend,
|
|
21
|
+
task_serializer="json",
|
|
22
|
+
accept_content=["json"],
|
|
23
|
+
result_serializer="json",
|
|
24
|
+
task_track_started=True,
|
|
25
|
+
task_time_limit=1800, # 30 minutes
|
|
26
|
+
worker_max_tasks_per_child=100,
|
|
27
|
+
broker_connection_retry_on_startup=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
class ContextTask(Task):
|
|
31
|
+
def __call__(self, *args: object, **kwargs: object) -> object:
|
|
32
|
+
with app.app_context():
|
|
33
|
+
return self.run(*args, **kwargs)
|
|
34
|
+
|
|
35
|
+
# Set ContextTask as the default task base class
|
|
36
|
+
celery.conf.update(task_default_queue="default")
|
|
37
|
+
celery.conf.task_cls = ContextTask
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Migrations initialization module for microservices."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from importlib import resources
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _copy_template_file(src_path: str, dest_path: str) -> None:
|
|
8
|
+
"""Copy a template file from src_path to dest_path."""
|
|
9
|
+
with (
|
|
10
|
+
open(src_path, encoding="utf-8") as src_file,
|
|
11
|
+
open(dest_path, "w", encoding="utf-8") as dest_file,
|
|
12
|
+
):
|
|
13
|
+
dest_file.write(src_file.read())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _copy_and_customize_env_py(
|
|
17
|
+
src_path: str, dest_path: str, models_import_statements: list[str] | None
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Copy env.py template and insert model imports if provided."""
|
|
20
|
+
with open(src_path, encoding="utf-8") as src_file:
|
|
21
|
+
env_template = src_file.read()
|
|
22
|
+
if models_import_statements:
|
|
23
|
+
model_imports = "\n".join(models_import_statements)
|
|
24
|
+
env_template = env_template.replace(
|
|
25
|
+
"# <IMPORT_MODELS_PLACEHOLDER>", model_imports
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
env_template = env_template.replace("# <IMPORT_MODELS_PLACEHOLDER>", "")
|
|
29
|
+
with open(dest_path, "w", encoding="utf-8") as dest_file:
|
|
30
|
+
dest_file.write(env_template)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ensure_migration_dirs(destination_dir: str) -> None:
|
|
34
|
+
"""Ensure the migrations and versions directories exist."""
|
|
35
|
+
os.makedirs(destination_dir, exist_ok=True)
|
|
36
|
+
versions_dir = os.path.join(destination_dir, "versions")
|
|
37
|
+
os.makedirs(versions_dir, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def setup_migrations(
|
|
41
|
+
destination_dir: str, models_import_statements: list[str] | None = None
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Set up the migrations directory for a microservice.
|
|
44
|
+
|
|
45
|
+
This function creates the basic structure for Alembic migrations
|
|
46
|
+
by copying template files from the zecmf package.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
destination_dir: The directory where migrations will be set up
|
|
50
|
+
models_import_statements: Optional list of import statements for models
|
|
51
|
+
to be included in the env.py file
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
_ensure_migration_dirs(destination_dir)
|
|
55
|
+
package_path = resources.files("zecmf.migrations")
|
|
56
|
+
package_path_str = str(package_path)
|
|
57
|
+
|
|
58
|
+
_copy_template_file(
|
|
59
|
+
os.path.join(package_path_str, "alembic.ini.template"),
|
|
60
|
+
os.path.join(destination_dir, "alembic.ini"),
|
|
61
|
+
)
|
|
62
|
+
_copy_template_file(
|
|
63
|
+
os.path.join(package_path_str, "script.py.mako"),
|
|
64
|
+
os.path.join(destination_dir, "script.py.mako"),
|
|
65
|
+
)
|
|
66
|
+
_copy_template_file(
|
|
67
|
+
os.path.join(package_path_str, "README.template"),
|
|
68
|
+
os.path.join(destination_dir, "README"),
|
|
69
|
+
)
|
|
70
|
+
_copy_and_customize_env_py(
|
|
71
|
+
os.path.join(package_path_str, "env.py.template"),
|
|
72
|
+
os.path.join(destination_dir, "env.py"),
|
|
73
|
+
models_import_statements,
|
|
74
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# A generic, single database configuration.
|
|
2
|
+
|
|
3
|
+
[alembic]
|
|
4
|
+
# path to migration scripts
|
|
5
|
+
script_location = migrations
|
|
6
|
+
|
|
7
|
+
# template used to generate migration files
|
|
8
|
+
# file_template = %%(rev)s_%%(slug)s
|
|
9
|
+
|
|
10
|
+
# timezone to use when rendering the date
|
|
11
|
+
# within the migration file as well as the filename.
|
|
12
|
+
# string value is passed to dateutil.tz.gettz()
|
|
13
|
+
# leave blank for localtime
|
|
14
|
+
# timezone =
|
|
15
|
+
|
|
16
|
+
# max length of characters to apply to the
|
|
17
|
+
# "slug" field
|
|
18
|
+
# truncate_slug_length = 40
|
|
19
|
+
|
|
20
|
+
# set to 'true' to run the environment during
|
|
21
|
+
# the 'revision' command, regardless of autogenerate
|
|
22
|
+
# revision_environment = false
|
|
23
|
+
|
|
24
|
+
# set to 'true' to allow .pyc and .pyo files without
|
|
25
|
+
# a source .py file to be detected as revisions in the
|
|
26
|
+
# versions/ directory
|
|
27
|
+
# sourceless = false
|
|
28
|
+
|
|
29
|
+
# version location specification; this defaults
|
|
30
|
+
# to alembic/versions. When using multiple version
|
|
31
|
+
# directories, initial revisions must be specified with --version-path
|
|
32
|
+
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
|
|
33
|
+
|
|
34
|
+
# the output encoding used when revision files
|
|
35
|
+
# are written from script.py.mako
|
|
36
|
+
# output_encoding = utf-8
|
|
37
|
+
|
|
38
|
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Logging configuration
|
|
42
|
+
[loggers]
|
|
43
|
+
keys = root,sqlalchemy,alembic
|
|
44
|
+
|
|
45
|
+
[handlers]
|
|
46
|
+
keys = console
|
|
47
|
+
|
|
48
|
+
[formatters]
|
|
49
|
+
keys = generic
|
|
50
|
+
|
|
51
|
+
[logger_root]
|
|
52
|
+
level = WARN
|
|
53
|
+
handlers = console
|
|
54
|
+
qualname =
|
|
55
|
+
|
|
56
|
+
[logger_sqlalchemy]
|
|
57
|
+
level = WARN
|
|
58
|
+
handlers =
|
|
59
|
+
qualname = sqlalchemy.engine
|
|
60
|
+
|
|
61
|
+
[logger_alembic]
|
|
62
|
+
level = INFO
|
|
63
|
+
handlers =
|
|
64
|
+
qualname = alembic
|
|
65
|
+
|
|
66
|
+
[handler_console]
|
|
67
|
+
class = StreamHandler
|
|
68
|
+
args = (sys.stderr,)
|
|
69
|
+
level = NOTSET
|
|
70
|
+
formatter = generic
|
|
71
|
+
|
|
72
|
+
[formatter_generic]
|
|
73
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
74
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Alembic environment configuration for database migrations."""
|
|
2
|
+
|
|
3
|
+
from logging.config import fileConfig
|
|
4
|
+
|
|
5
|
+
from alembic import context
|
|
6
|
+
from flask import current_app
|
|
7
|
+
|
|
8
|
+
# this is the Alembic Config object, which provides
|
|
9
|
+
# access to the values within the .ini file in use.
|
|
10
|
+
config = context.config
|
|
11
|
+
|
|
12
|
+
# Interpret the config file for Python logging.
|
|
13
|
+
# This line sets up loggers basically.
|
|
14
|
+
fileConfig(config.config_file_name)
|
|
15
|
+
|
|
16
|
+
# add your model's MetaData object here
|
|
17
|
+
# for 'autogenerate' support
|
|
18
|
+
from zecmf.extensions.database import db
|
|
19
|
+
|
|
20
|
+
target_metadata = db.metadata
|
|
21
|
+
|
|
22
|
+
# Import all models to ensure they're registered with SQLAlchemy
|
|
23
|
+
# The actual import statements will be populated by the setup process
|
|
24
|
+
# <IMPORT_MODELS_PLACEHOLDER>
|
|
25
|
+
|
|
26
|
+
# other values from the config, defined by the needs of env.py,
|
|
27
|
+
# can be acquired:
|
|
28
|
+
# my_important_option = config.get_main_option("my_important_option")
|
|
29
|
+
# ... etc.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_url() -> str:
|
|
33
|
+
"""Get database URL from Flask app config."""
|
|
34
|
+
return current_app.config.get("SQLALCHEMY_DATABASE_URI")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_migrations_offline() -> None:
|
|
38
|
+
"""Run migrations in 'offline' mode.
|
|
39
|
+
|
|
40
|
+
This configures the context with just a URL
|
|
41
|
+
and not an Engine, though an Engine is acceptable
|
|
42
|
+
here as well. By skipping the Engine creation
|
|
43
|
+
we don't even need a DBAPI to be available.
|
|
44
|
+
|
|
45
|
+
Calls to context.execute() here emit the given string to the
|
|
46
|
+
script output.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
url = get_url()
|
|
50
|
+
context.configure(
|
|
51
|
+
url=url,
|
|
52
|
+
target_metadata=target_metadata,
|
|
53
|
+
literal_binds=True,
|
|
54
|
+
dialect_opts={"paramstyle": "named"},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
with context.begin_transaction():
|
|
58
|
+
context.run_migrations()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_migrations_online() -> None:
|
|
62
|
+
"""Run migrations in 'online' mode.
|
|
63
|
+
|
|
64
|
+
In this scenario we need to create an Engine
|
|
65
|
+
and associate a connection with the context.
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
# this callback is used to prevent an auto-migration from being generated
|
|
70
|
+
# when there are no changes to the schema
|
|
71
|
+
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
|
72
|
+
def process_revision_directives(
|
|
73
|
+
context_: object, revision: object, directives: list
|
|
74
|
+
) -> None:
|
|
75
|
+
if getattr(config.cmd_opts, "autogenerate", False):
|
|
76
|
+
script = directives[0]
|
|
77
|
+
if script.upgrade_ops.is_empty():
|
|
78
|
+
directives[:] = []
|
|
79
|
+
|
|
80
|
+
from zecmf.extensions.database import db
|
|
81
|
+
connectable = db.engine
|
|
82
|
+
|
|
83
|
+
with connectable.connect() as connection:
|
|
84
|
+
context.configure(
|
|
85
|
+
connection=connection,
|
|
86
|
+
target_metadata=target_metadata,
|
|
87
|
+
process_revision_directives=process_revision_directives,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
with context.begin_transaction():
|
|
91
|
+
context.run_migrations()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if context.is_offline_mode():
|
|
95
|
+
run_migrations_offline()
|
|
96
|
+
else:
|
|
97
|
+
run_migrations_online()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from alembic import op
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
${imports if imports else ""}
|
|
11
|
+
|
|
12
|
+
# revision identifiers, used by Alembic.
|
|
13
|
+
revision = ${repr(up_revision)}
|
|
14
|
+
down_revision = ${repr(down_revision)}
|
|
15
|
+
branch_labels = ${repr(branch_labels)}
|
|
16
|
+
depends_on = ${repr(depends_on)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade():
|
|
20
|
+
${upgrades if upgrades else "pass"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def downgrade():
|
|
24
|
+
${downgrades if downgrades else "pass"}
|
zecmf/testing/config.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Constants for testing."""
|
|
2
|
+
|
|
3
|
+
from zecmf.constants import HTTPStatus
|
|
4
|
+
|
|
5
|
+
# Re-export HTTP status codes for convenience in tests
|
|
6
|
+
HTTP_OK = HTTPStatus.OK
|
|
7
|
+
HTTP_CREATED = HTTPStatus.CREATED
|
|
8
|
+
HTTP_ACCEPTED = HTTPStatus.ACCEPTED
|
|
9
|
+
HTTP_NO_CONTENT = HTTPStatus.NO_CONTENT
|
|
10
|
+
HTTP_BAD_REQUEST = HTTPStatus.BAD_REQUEST
|
|
11
|
+
HTTP_UNAUTHORIZED = HTTPStatus.UNAUTHORIZED
|
|
12
|
+
HTTP_FORBIDDEN = HTTPStatus.FORBIDDEN
|
|
13
|
+
HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND
|
|
14
|
+
HTTP_CONFLICT = HTTPStatus.CONFLICT
|
|
15
|
+
HTTP_UNPROCESSABLE_ENTITY = HTTPStatus.UNPROCESSABLE_ENTITY
|
|
16
|
+
HTTP_INTERNAL_SERVER_ERROR = HTTPStatus.INTERNAL_SERVER_ERROR
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zecmf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A framework for building microservices in Python
|
|
5
|
+
Author-email: Hendrik Buchwald <hb@zecure.org>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: flask==3.1.1
|
|
10
|
+
Requires-Dist: flask-restx==1.3.0
|
|
11
|
+
Requires-Dist: jsonschema==4.17.3
|
|
12
|
+
Requires-Dist: flask-sqlalchemy==3.1.1
|
|
13
|
+
Requires-Dist: flask-migrate==4.1.0
|
|
14
|
+
Requires-Dist: flask-jwt-extended==4.7.1
|
|
15
|
+
Requires-Dist: werkzeug==3.1.3
|
|
16
|
+
Requires-Dist: sqlalchemy==2.0.41
|
|
17
|
+
Requires-Dist: click==8.2.0
|
|
18
|
+
Requires-Dist: celery==5.5.2
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest==8.3.5; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-cov==6.1.1; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff==0.11.10; extra == "dev"
|
|
23
|
+
Requires-Dist: mypy==1.15.0; extra == "dev"
|
|
24
|
+
Requires-Dist: types-Flask-Migrate==4.1.0.20250112; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# Zecure Microservices Framework (ZecMF)
|
|
27
|
+
|
|
28
|
+
A lightweight framework for building microservices in Python with Flask.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Application Factory**: Streamlined Flask application initialization
|
|
33
|
+
- **JWT Authentication**: Built-in JWT authentication with both RS256 and HS256 support
|
|
34
|
+
- **API Setup**: Simplified REST API initialization with Flask-RESTX
|
|
35
|
+
- **Database**: SQLAlchemy and Alembic integration
|
|
36
|
+
- **CLI Commands**: Common CLI commands for microservice management
|
|
37
|
+
- **Configuration**: Hierarchical configuration system with framework defaults and app-specific overrides
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install zecmf
|
|
43
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
zecmf/__init__.py,sha256=d2vk_Z5YhJRa3-1MgSHPbMeuFiWPFHRRnZeoyE5Yd4U,154
|
|
2
|
+
zecmf/app.py,sha256=iX4h1qJ1BSMeRsOqI7JeFtUGf4ORcA361VMWH4zBF-w,2392
|
|
3
|
+
zecmf/constants.py,sha256=Mz4mO1uf--POUxZGD9YmAB7FiObjZi7NKytrQkSRpVY,802
|
|
4
|
+
zecmf/api/__init__.py,sha256=IEskBWccYpE4dz4_lAY5QMHv3tN62fi4LitT9IpIrbI,1189
|
|
5
|
+
zecmf/auth/__init__.py,sha256=vtob-F64LWHkCVJenTZUsovmQXNyqnBWXlKQt5aVq1Q,385
|
|
6
|
+
zecmf/auth/decorators.py,sha256=WZGjs-zxmnCwXpupJbOtBeeAoUJxUIqc66cjjoYg6vs,2443
|
|
7
|
+
zecmf/auth/jwt.py,sha256=RGEjiioHtDPlTg9aMXm1obiRIZ5uJL98dLJpWBOIV9c,2763
|
|
8
|
+
zecmf/cli/__init__.py,sha256=qmJkv898McOFTGZ04oX68pK6OGAR4gkVxsYghU9_2ig,143
|
|
9
|
+
zecmf/cli/commands.py,sha256=GJmoGcOpADEvajqgHHhwLWbwQ3Qu0h7koL1jHt_u9EQ,5735
|
|
10
|
+
zecmf/config/__init__.py,sha256=fH27K8pLzAQ5Owe1j-f203p60GGy6ClYkxdkfDlbzwo,1936
|
|
11
|
+
zecmf/config/base.py,sha256=SQBza11KRBYGFo1KTWPmJ1WU1EKGOnb6V2nhXeShX38,6940
|
|
12
|
+
zecmf/extensions/__init__.py,sha256=4XJ7BIhRI_xXDZH-VH4nqgYgo2GZWhUayXOGMOsqg5U,48
|
|
13
|
+
zecmf/extensions/database.py,sha256=ZQxcRRpLyhSo41wkE1PoJmjvB_E87RZafZUxVZR_LX0,420
|
|
14
|
+
zecmf/extensions/queue.py,sha256=_Xk3oh_jC8CL0SGCUN7ExmvzFqNZ2nM6MAXuLZ0-TOE,1152
|
|
15
|
+
zecmf/migrations/README.template,sha256=rHo53QkNpXmN-V8qdmg4-jQ3_ZgGVr86IAGVk4szxfE,118
|
|
16
|
+
zecmf/migrations/__init__.py,sha256=WyaU-o0KinnbOTGp61x-fxI7pr_LKGVRvko9iPNbpPg,2628
|
|
17
|
+
zecmf/migrations/alembic.ini.template,sha256=PgPI8bQQJ-wzmsU8blYSvenGwi2H0s6KXgHoEl-8DUY,1650
|
|
18
|
+
zecmf/migrations/env.py.template,sha256=zqRo_IROKTT-PEhsimGdlzNWK96itAN575U7PlQdS0k,2810
|
|
19
|
+
zecmf/migrations/script.py.mako,sha256=LiqCKQAJ2Wbqo1HT-HsFMsDtqPF8kWX7Yudz0AXGEQ4,493
|
|
20
|
+
zecmf/testing/__init__.py,sha256=4EASYNTYXwDxKZkSkgRkVUun3Kvybk6p5QVw9DMWfTQ,105
|
|
21
|
+
zecmf/testing/config.py,sha256=W878tu0HdGNo4ZlmjXVsoyxSHyNwoYsqweWNl2r508I,222
|
|
22
|
+
zecmf/testing/constants.py,sha256=ecltZHKmlNlvSrmq0v2bQxaj5v7_kM7Ko6DMOn2iHvw,579
|
|
23
|
+
zecmf-0.1.0.dist-info/METADATA,sha256=XzQc6OFB5hThipCUVCHz84gHcxifNN-nvKFa_wkJskU,1505
|
|
24
|
+
zecmf-0.1.0.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
|
|
25
|
+
zecmf-0.1.0.dist-info/top_level.txt,sha256=xFSLpzOVc-oDRAr-bijDqsE5Cbm_Y7hn8kGDPN3b32A,6
|
|
26
|
+
zecmf-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zecmf
|