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 ADDED
@@ -0,0 +1,7 @@
1
+ """ZecMF - A lightweight framework for building microservices in Python with Flask."""
2
+
3
+ from zecmf.app import create_app
4
+
5
+ __all__ = [
6
+ "create_app",
7
+ ]
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
+ ]
@@ -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
@@ -0,0 +1,5 @@
1
+ """Command-line interface module for Flask applications."""
2
+
3
+ from zecmf.cli.commands import register_commands
4
+
5
+ __all__ = ["register_commands"]
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)
@@ -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,3 @@
1
+ Generic single-database configuration with Alembic.
2
+
3
+ This directory contains database migration scripts using Alembic.
@@ -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"}
@@ -0,0 +1,3 @@
1
+ """Testing utilities for micro-framework applications."""
2
+
3
+ from zecmf.testing.constants import * # noqa
@@ -0,0 +1,9 @@
1
+ """Used by ZecMF-internal tests as app_config_module."""
2
+
3
+ from zecmf.config.base import BaseTestingConfig
4
+
5
+
6
+ class TestingConfig(BaseTestingConfig):
7
+ """Testing configuration for unit and integration tests."""
8
+
9
+ pass
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.8.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ zecmf