platzky 0.4.1__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. platzky-1.0.0/LICENSE +21 -0
  2. {platzky-0.4.1 → platzky-1.0.0}/PKG-INFO +12 -1
  3. platzky-1.0.0/platzky/config.py +207 -0
  4. {platzky-0.4.1 → platzky-1.0.0}/platzky/db/db.py +9 -0
  5. {platzky-0.4.1 → platzky-1.0.0}/platzky/db/graph_ql_db.py +15 -0
  6. {platzky-0.4.1 → platzky-1.0.0}/platzky/db/json_db.py +8 -0
  7. {platzky-0.4.1 → platzky-1.0.0}/platzky/db/mongodb_db.py +8 -0
  8. platzky-1.0.0/platzky/engine.py +141 -0
  9. {platzky-0.4.1 → platzky-1.0.0}/platzky/platzky.py +20 -0
  10. platzky-1.0.0/platzky/telemetry.py +123 -0
  11. {platzky-0.4.1 → platzky-1.0.0}/pyproject.toml +48 -6
  12. platzky-0.4.1/platzky/config.py +0 -69
  13. platzky-0.4.1/platzky/engine.py +0 -71
  14. {platzky-0.4.1 → platzky-1.0.0}/README.md +0 -0
  15. {platzky-0.4.1 → platzky-1.0.0}/platzky/__init__.py +0 -0
  16. {platzky-0.4.1 → platzky-1.0.0}/platzky/admin/admin.py +0 -0
  17. {platzky-0.4.1 → platzky-1.0.0}/platzky/admin/fake_login.py +0 -0
  18. {platzky-0.4.1 → platzky-1.0.0}/platzky/admin/templates/admin.html +0 -0
  19. {platzky-0.4.1 → platzky-1.0.0}/platzky/admin/templates/login.html +0 -0
  20. {platzky-0.4.1 → platzky-1.0.0}/platzky/admin/templates/module.html +0 -0
  21. {platzky-0.4.1 → platzky-1.0.0}/platzky/blog/__init__.py +0 -0
  22. {platzky-0.4.1 → platzky-1.0.0}/platzky/blog/blog.py +0 -0
  23. {platzky-0.4.1 → platzky-1.0.0}/platzky/blog/comment_form.py +0 -0
  24. {platzky-0.4.1 → platzky-1.0.0}/platzky/db/README.md +0 -0
  25. {platzky-0.4.1 → platzky-1.0.0}/platzky/db/__init__.py +0 -0
  26. {platzky-0.4.1 → platzky-1.0.0}/platzky/db/db_loader.py +0 -0
  27. {platzky-0.4.1 → platzky-1.0.0}/platzky/db/github_json_db.py +0 -0
  28. {platzky-0.4.1 → platzky-1.0.0}/platzky/db/google_json_db.py +0 -0
  29. {platzky-0.4.1 → platzky-1.0.0}/platzky/db/json_file_db.py +0 -0
  30. {platzky-0.4.1 → platzky-1.0.0}/platzky/locale/en/LC_MESSAGES/messages.po +0 -0
  31. {platzky-0.4.1 → platzky-1.0.0}/platzky/locale/pl/LC_MESSAGES/messages.po +0 -0
  32. {platzky-0.4.1 → platzky-1.0.0}/platzky/models.py +0 -0
  33. {platzky-0.4.1 → platzky-1.0.0}/platzky/plugin/plugin.py +0 -0
  34. {platzky-0.4.1 → platzky-1.0.0}/platzky/plugin/plugin_loader.py +0 -0
  35. {platzky-0.4.1 → platzky-1.0.0}/platzky/seo/seo.py +0 -0
  36. {platzky-0.4.1 → platzky-1.0.0}/platzky/static/blog.css +0 -0
  37. {platzky-0.4.1 → platzky-1.0.0}/platzky/static/styles.css +0 -0
  38. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/404.html +0 -0
  39. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/base.html +0 -0
  40. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/blog.html +0 -0
  41. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/body_meta.html +0 -0
  42. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/dynamic_css.html +0 -0
  43. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/feed.xml +0 -0
  44. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/head_meta.html +0 -0
  45. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/page.html +0 -0
  46. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/post.html +0 -0
  47. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/robots.txt +0 -0
  48. {platzky-0.4.1 → platzky-1.0.0}/platzky/templates/sitemap.xml +0 -0
  49. {platzky-0.4.1 → platzky-1.0.0}/platzky/www_handler.py +0 -0
platzky-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Platzky Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: platzky
3
- Version: 0.4.1
3
+ Version: 1.0.0
4
4
  Summary: Not only blog engine
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Requires-Python: >=3.10,<4.0
7
8
  Classifier: License :: OSI Approved :: MIT License
8
9
  Classifier: Programming Language :: Python :: 3
@@ -11,6 +12,8 @@ Classifier: Programming Language :: Python :: 3.11
11
12
  Classifier: Programming Language :: Python :: 3.12
12
13
  Classifier: Programming Language :: Python :: 3.13
13
14
  Classifier: Programming Language :: Python :: 3.14
15
+ Provides-Extra: docs
16
+ Provides-Extra: telemetry
14
17
  Requires-Dist: Flask (==3.0.3)
15
18
  Requires-Dist: Flask-Babel (>=4.0.0,<5.0.0)
16
19
  Requires-Dist: Flask-Minify (>=0.42,<0.43)
@@ -21,9 +24,17 @@ Requires-Dist: deprecation (>=2.1.0,<3.0.0)
21
24
  Requires-Dist: google-cloud-storage (>=2.5.0,<3.0.0)
22
25
  Requires-Dist: gql (>=3.4.0,<4.0.0)
23
26
  Requires-Dist: humanize (>=4.9.0,<5.0.0)
27
+ Requires-Dist: myst-parser (>=4.0.0,<5.0.0) ; extra == "docs"
28
+ Requires-Dist: opentelemetry-api (>=1.27.0,<2.0.0) ; extra == "telemetry"
29
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc (>=1.27.0,<2.0.0) ; extra == "telemetry"
30
+ Requires-Dist: opentelemetry-instrumentation-flask (>=0.48b0,<0.49) ; extra == "telemetry"
31
+ Requires-Dist: opentelemetry-sdk (>=1.27.0,<2.0.0) ; extra == "telemetry"
24
32
  Requires-Dist: pydantic (>=2.7.1,<3.0.0)
25
33
  Requires-Dist: pygithub (>=2.6.1,<3.0.0)
26
34
  Requires-Dist: pymongo (>=4.7.0,<5.0.0)
35
+ Requires-Dist: sphinx (>=8.0.0,<9.0.0) ; extra == "docs"
36
+ Requires-Dist: sphinx-rtd-theme (>=3.0.0,<4.0.0) ; extra == "docs"
37
+ Requires-Dist: tomli (>=2.0.0,<3.0.0) ; extra == "docs"
27
38
  Description-Content-Type: text/markdown
28
39
 
29
40
  ![Github Actions](https://github.com/platzky/platzky/actions/workflows/tests.yml/badge.svg?event=push&branch=main)
@@ -0,0 +1,207 @@
1
+ """Configuration module for Platzky application.
2
+
3
+ This module defines all configuration models and parsing logic for the application.
4
+ """
5
+
6
+ import sys
7
+ import typing as t
8
+
9
+ import yaml
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
11
+
12
+ from .db.db import DBConfig
13
+ from .db.db_loader import get_db_module
14
+
15
+
16
+ class StrictBaseModel(BaseModel):
17
+ """Base model with immutable (frozen) configuration."""
18
+
19
+ model_config = ConfigDict(frozen=True)
20
+
21
+
22
+ class LanguageConfig(StrictBaseModel):
23
+ """Configuration for a single language.
24
+
25
+ Attributes:
26
+ name: Display name of the language
27
+ flag: Flag icon code (country code)
28
+ country: Country code
29
+ domain: Optional domain specific to this language
30
+ """
31
+
32
+ name: str = Field(alias="name")
33
+ flag: str = Field(alias="flag")
34
+ country: str = Field(alias="country")
35
+ domain: t.Optional[str] = Field(default=None, alias="domain")
36
+
37
+
38
+ Languages = dict[str, LanguageConfig]
39
+ LanguagesMapping = t.Mapping[str, t.Mapping[str, str]]
40
+
41
+ # Validation error messages
42
+ _INVALID_ENDPOINT_FORMAT_MSG = (
43
+ "Invalid endpoint: '{}'. Must be host:port or [http|https]://host[:port]"
44
+ )
45
+ _INVALID_ENDPOINT_SCHEME_MSG = "Invalid endpoint scheme: '{}'. Must be http or https"
46
+ _MISSING_HOSTNAME_MSG = "Invalid endpoint: '{}'. Missing hostname"
47
+
48
+
49
+ def languages_dict(languages: Languages) -> LanguagesMapping:
50
+ """Convert Languages configuration to a mapping dictionary.
51
+
52
+ Excludes None values to align with type signature.
53
+
54
+ Args:
55
+ languages: Dictionary of language configurations
56
+
57
+ Returns:
58
+ Mapping of language codes to their configuration dictionaries (excludes None values)
59
+ """
60
+ return {
61
+ name: {k: v for k, v in lang.model_dump().items() if v is not None}
62
+ for name, lang in languages.items()
63
+ }
64
+
65
+
66
+ class TelemetryConfig(StrictBaseModel):
67
+ """OpenTelemetry configuration for application tracing.
68
+
69
+ Attributes:
70
+ enabled: Enable or disable telemetry tracing
71
+ endpoint: OTLP gRPC endpoint (e.g., localhost:4317 or http://localhost:4317)
72
+ console_export: Export traces to console for debugging
73
+ timeout: Timeout in seconds for exporter (default: 10)
74
+ deployment_environment: Deployment environment (e.g., production, staging, dev)
75
+ service_instance_id: Service instance ID (auto-generated if not provided)
76
+ flush_on_request: Flush spans after each request (default: True, may impact latency)
77
+ flush_timeout_ms: Timeout in milliseconds for per-request flush (default: 5000)
78
+ """
79
+
80
+ enabled: bool = Field(default=False, alias="enabled")
81
+ endpoint: t.Optional[str] = Field(default=None, alias="endpoint")
82
+ console_export: bool = Field(default=False, alias="console_export")
83
+ timeout: int = Field(default=10, alias="timeout", gt=0)
84
+ deployment_environment: t.Optional[str] = Field(default=None, alias="deployment_environment")
85
+ service_instance_id: t.Optional[str] = Field(default=None, alias="service_instance_id")
86
+ flush_on_request: bool = Field(default=True, alias="flush_on_request")
87
+ flush_timeout_ms: int = Field(default=5000, alias="flush_timeout_ms", gt=0)
88
+
89
+ @field_validator("endpoint")
90
+ @classmethod
91
+ def validate_endpoint(cls, v: t.Optional[str]) -> t.Optional[str]:
92
+ """Validate endpoint URL format.
93
+
94
+ Accepts OTLP/gRPC spec-compliant formats:
95
+ - host:port (e.g., localhost:4317)
96
+ - http://host[:port]
97
+ - https://host[:port]
98
+
99
+ Note: grpc:// scheme is NOT supported per OTLP spec and will be rejected.
100
+ """
101
+ if v is None:
102
+ return v
103
+
104
+ from urllib.parse import urlparse
105
+
106
+ # Check if it has a scheme (contains ://)
107
+ if "://" not in v:
108
+ # Must be host:port format - validate it has a colon
109
+ if ":" in v and not v.startswith("/"):
110
+ return v
111
+ raise ValueError(_INVALID_ENDPOINT_FORMAT_MSG.format(v))
112
+
113
+ # Parse URL with scheme
114
+ parsed = urlparse(v)
115
+
116
+ # Validate scheme (only http/https per OTLP spec, grpc is NOT supported)
117
+ if parsed.scheme not in ("http", "https"):
118
+ raise ValueError(_INVALID_ENDPOINT_SCHEME_MSG.format(parsed.scheme))
119
+
120
+ # Validate hostname exists
121
+ if not parsed.hostname:
122
+ raise ValueError(_MISSING_HOSTNAME_MSG.format(v))
123
+
124
+ return v
125
+
126
+
127
+ class Config(StrictBaseModel):
128
+ """Main application configuration.
129
+
130
+ Attributes:
131
+ app_name: Application name
132
+ secret_key: Flask secret key for sessions
133
+ db: Database configuration
134
+ use_www: Redirect non-www to www URLs
135
+ seo_prefix: URL prefix for SEO routes
136
+ blog_prefix: URL prefix for blog routes
137
+ languages: Supported languages configuration
138
+ domain_to_lang: Domain to language mapping
139
+ translation_directories: Additional translation directories
140
+ debug: Enable debug mode
141
+ testing: Enable testing mode
142
+ feature_flags: Feature flag configuration
143
+ telemetry: OpenTelemetry configuration
144
+ """
145
+
146
+ app_name: str = Field(alias="APP_NAME")
147
+ secret_key: str = Field(alias="SECRET_KEY")
148
+ db: DBConfig = Field(alias="DB")
149
+ use_www: bool = Field(default=True, alias="USE_WWW")
150
+ seo_prefix: str = Field(default="/", alias="SEO_PREFIX")
151
+ blog_prefix: str = Field(default="/", alias="BLOG_PREFIX")
152
+ languages: Languages = Field(default_factory=dict, alias="LANGUAGES")
153
+ domain_to_lang: dict[str, str] = Field(default_factory=dict, alias="DOMAIN_TO_LANG")
154
+ translation_directories: list[str] = Field(
155
+ default_factory=list,
156
+ alias="TRANSLATION_DIRECTORIES",
157
+ )
158
+ debug: bool = Field(default=False, alias="DEBUG")
159
+ testing: bool = Field(default=False, alias="TESTING")
160
+ feature_flags: t.Optional[dict[str, bool]] = Field(default=None, alias="FEATURE_FLAGS")
161
+ telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig, alias="TELEMETRY")
162
+
163
+ @classmethod
164
+ def model_validate(
165
+ cls,
166
+ obj: t.Any,
167
+ *,
168
+ strict: bool | None = None,
169
+ from_attributes: bool | None = None,
170
+ context: dict[str, t.Any] | None = None,
171
+ ) -> "Config":
172
+ """Validate and construct Config from dictionary.
173
+
174
+ Args:
175
+ obj: Configuration dictionary
176
+ strict: Enable strict validation
177
+ from_attributes: Populate from object attributes
178
+ context: Additional validation context
179
+
180
+ Returns:
181
+ Validated Config instance
182
+ """
183
+ db_cfg_type = get_db_module(obj["DB"]["TYPE"]).db_config_type()
184
+ obj["DB"] = db_cfg_type.model_validate(obj["DB"])
185
+ return super().model_validate(
186
+ obj, strict=strict, from_attributes=from_attributes, context=context
187
+ )
188
+
189
+ @classmethod
190
+ def parse_yaml(cls, path: str) -> "Config":
191
+ """Parse configuration from YAML file.
192
+
193
+ Args:
194
+ path: Path to YAML configuration file
195
+
196
+ Returns:
197
+ Validated Config instance
198
+
199
+ Raises:
200
+ SystemExit: If config file is not found
201
+ """
202
+ try:
203
+ with open(path, "r") as f:
204
+ return cls.model_validate(yaml.safe_load(f))
205
+ except FileNotFoundError:
206
+ print(f"Config file not found: {path}", file=sys.stderr)
207
+ raise SystemExit(1)
@@ -96,6 +96,15 @@ class DB(ABC):
96
96
  def get_font(self) -> str:
97
97
  pass
98
98
 
99
+ @abstractmethod
100
+ def health_check(self) -> None:
101
+ """Perform a health check on the database.
102
+
103
+ Should raise an exception if the database is not healthy.
104
+ This should be a lightweight operation suitable for health checks.
105
+ """
106
+ pass
107
+
99
108
 
100
109
  class DBConfig(BaseModel):
101
110
  type: str = Field(alias="TYPE")
@@ -307,3 +307,18 @@ class GraphQL(DB):
307
307
  """
308
308
  )
309
309
  return self.client.execute(plugins_data)["pluginConfigs"]
310
+
311
+ def health_check(self) -> None:
312
+ """Perform a health check on the GraphQL database.
313
+
314
+ Raises an exception if the database is not accessible.
315
+ """
316
+ # Simple query to check connectivity
317
+ health_query = gql(
318
+ """
319
+ query {
320
+ __typename
321
+ }
322
+ """
323
+ )
324
+ self.client.execute(health_query)
@@ -116,3 +116,11 @@ class Json(DB):
116
116
 
117
117
  def get_plugins_data(self):
118
118
  return self.data.get("plugins", [])
119
+
120
+ def health_check(self) -> None:
121
+ """Perform a health check on the JSON database.
122
+
123
+ Raises an exception if the database is not accessible.
124
+ """
125
+ # Try to access site_content to ensure basic structure is valid
126
+ self._get_site_content()
@@ -125,6 +125,14 @@ class MongoDB(DB):
125
125
  return site_content.get("font", "")
126
126
  return ""
127
127
 
128
+ def health_check(self) -> None:
129
+ """Perform a health check on the MongoDB database.
130
+
131
+ Raises an exception if the database is not accessible.
132
+ """
133
+ # Simple ping to check if database is accessible
134
+ self.client.admin.command("ping")
135
+
128
136
  def _close_connection(self) -> None:
129
137
  """Close the MongoDB connection"""
130
138
  if self.client:
@@ -0,0 +1,141 @@
1
+ import os
2
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError
3
+ from typing import Any, Callable, Dict, List, Tuple
4
+
5
+ from flask import Blueprint, Flask, jsonify, make_response, request, session
6
+ from flask_babel import Babel
7
+
8
+ from platzky.config import Config
9
+ from platzky.models import CmsModule
10
+
11
+
12
+ class Engine(Flask):
13
+ def __init__(self, config: Config, db, import_name):
14
+ super().__init__(import_name)
15
+ self.config.from_mapping(config.model_dump(by_alias=True))
16
+ self.db = db
17
+ self.notifiers = []
18
+ self.login_methods = []
19
+ self.dynamic_body = ""
20
+ self.dynamic_head = ""
21
+ self.health_checks: List[Tuple[str, Callable[[], None]]] = []
22
+ self.telemetry_instrumented: bool = False
23
+ directory = os.path.dirname(os.path.realpath(__file__))
24
+ locale_dir = os.path.join(directory, "locale")
25
+ config.translation_directories.append(locale_dir)
26
+ babel_translation_directories = ";".join(config.translation_directories)
27
+ self.babel = Babel(
28
+ self,
29
+ locale_selector=self.get_locale,
30
+ default_translation_directories=babel_translation_directories,
31
+ )
32
+ self._register_default_health_endpoints()
33
+
34
+ self.cms_modules: List[CmsModule] = []
35
+ # TODO add plugins as CMS Module - all plugins should be visible from
36
+ # admin page at least as configuration
37
+
38
+ def notify(self, message: str):
39
+ for notifier in self.notifiers:
40
+ notifier(message)
41
+
42
+ def add_notifier(self, notifier):
43
+ self.notifiers.append(notifier)
44
+
45
+ def add_cms_module(self, module: CmsModule):
46
+ """Add a CMS module to the modules list."""
47
+ self.cms_modules.append(module)
48
+
49
+ # TODO login_method should be interface
50
+ def add_login_method(self, login_method):
51
+ self.login_methods.append(login_method)
52
+
53
+ def add_dynamic_body(self, body: str):
54
+ self.dynamic_body += body
55
+
56
+ def add_dynamic_head(self, body: str):
57
+ self.dynamic_head += body
58
+
59
+ def get_locale(self) -> str:
60
+ domain = request.headers.get("Host", "localhost")
61
+ domain_to_lang = self.config.get("DOMAIN_TO_LANG")
62
+
63
+ languages = self.config.get("LANGUAGES", {}).keys()
64
+ backup_lang = session.get(
65
+ "language",
66
+ request.accept_languages.best_match(languages, "en"),
67
+ )
68
+
69
+ if domain_to_lang:
70
+ lang = domain_to_lang.get(domain, backup_lang)
71
+ else:
72
+ lang = backup_lang
73
+
74
+ session["language"] = lang
75
+ return lang
76
+
77
+ def add_health_check(self, name: str, check_function: Callable[[], None]) -> None:
78
+ """Register a health check function"""
79
+ if not callable(check_function):
80
+ raise TypeError(f"check_function must be callable, got {type(check_function)}")
81
+ self.health_checks.append((name, check_function))
82
+
83
+ def _register_default_health_endpoints(self):
84
+ """Register default health endpoints"""
85
+
86
+ health_bp = Blueprint("health", __name__)
87
+ HEALTH_CHECK_TIMEOUT = 10 # seconds
88
+
89
+ @health_bp.route("/health/liveness")
90
+ def liveness():
91
+ """Simple liveness check - is the app running?"""
92
+ return jsonify({"status": "alive"}), 200
93
+
94
+ @health_bp.route("/health/readiness")
95
+ def readiness():
96
+ """Readiness check - can the app serve traffic?"""
97
+ health_status: Dict[str, Any] = {"status": "ready", "checks": {}}
98
+ status_code = 200
99
+
100
+ executor = ThreadPoolExecutor(max_workers=1)
101
+ try:
102
+ # Database health check with timeout
103
+ future = executor.submit(self.db.health_check)
104
+ try:
105
+ future.result(timeout=HEALTH_CHECK_TIMEOUT)
106
+ health_status["checks"]["database"] = "ok"
107
+ except TimeoutError:
108
+ health_status["checks"]["database"] = "failed: timeout"
109
+ health_status["status"] = "not_ready"
110
+ status_code = 503
111
+ except Exception as e:
112
+ health_status["checks"]["database"] = f"failed: {e!s}"
113
+ health_status["status"] = "not_ready"
114
+ status_code = 503
115
+
116
+ # Run application-registered health checks
117
+ for check_name, check_func in self.health_checks:
118
+ future = executor.submit(check_func)
119
+ try:
120
+ future.result(timeout=HEALTH_CHECK_TIMEOUT)
121
+ health_status["checks"][check_name] = "ok"
122
+ except TimeoutError:
123
+ health_status["checks"][check_name] = "failed: timeout"
124
+ health_status["status"] = "not_ready"
125
+ status_code = 503
126
+ except Exception as e:
127
+ health_status["checks"][check_name] = f"failed: {e!s}"
128
+ health_status["status"] = "not_ready"
129
+ status_code = 503
130
+ finally:
131
+ # Shutdown without waiting if any futures are still running
132
+ executor.shutdown(wait=False)
133
+
134
+ return make_response(jsonify(health_status), status_code)
135
+
136
+ # Simple /health alias for liveness
137
+ @health_bp.route("/health")
138
+ def health():
139
+ return liveness()
140
+
141
+ self.register_blueprint(health_bp)
@@ -17,6 +17,12 @@ from platzky.plugin.plugin_loader import plugify
17
17
  from platzky.seo import seo
18
18
  from platzky.www_handler import redirect_nonwww_to_www, redirect_www_to_nonwww
19
19
 
20
+ _MISSING_OTEL_MSG = (
21
+ "OpenTelemetry is not installed. Install with: "
22
+ "poetry add opentelemetry-api opentelemetry-sdk "
23
+ "opentelemetry-instrumentation-flask opentelemetry-exporter-otlp-proto-grpc"
24
+ )
25
+
20
26
 
21
27
  def create_engine(config: Config, db) -> Engine:
22
28
  app = Engine(config, db, __name__)
@@ -85,6 +91,20 @@ def create_app_from_config(config: Config) -> Engine:
85
91
  db = get_db(config.db)
86
92
  engine = create_engine(config, db)
87
93
 
94
+ # Setup telemetry (optional feature)
95
+ if config.telemetry.enabled:
96
+ try:
97
+ from platzky.telemetry import setup_telemetry
98
+
99
+ setup_telemetry(engine, config.telemetry)
100
+ except ImportError as e:
101
+ raise ImportError(_MISSING_OTEL_MSG) from e
102
+ except ValueError as e:
103
+ raise ValueError(
104
+ f"Telemetry configuration error: {e}. "
105
+ "Check your telemetry settings in the configuration file."
106
+ ) from e
107
+
88
108
  admin_blueprint = admin.create_admin_blueprint(
89
109
  login_methods=engine.login_methods, cms_modules=engine.cms_modules
90
110
  )
@@ -0,0 +1,123 @@
1
+ import atexit
2
+ import socket
3
+ import uuid
4
+ from typing import TYPE_CHECKING, Optional
5
+
6
+ from platzky.config import TelemetryConfig
7
+
8
+ if TYPE_CHECKING:
9
+ from opentelemetry.trace import Tracer
10
+
11
+ from platzky.engine import Engine
12
+
13
+ # Error messages
14
+ _MISSING_EXPORTERS_MSG = (
15
+ "Telemetry is enabled but no exporters are configured. "
16
+ "Set endpoint or console_export=True to export traces."
17
+ )
18
+
19
+
20
+ def setup_telemetry(app: "Engine", telemetry_config: TelemetryConfig) -> Optional["Tracer"]:
21
+ """Setup OpenTelemetry tracing for Flask application.
22
+
23
+ Configures and initializes OpenTelemetry tracing with OTLP and/or console exporters.
24
+ Automatically instruments Flask to capture HTTP requests and trace information.
25
+
26
+ Args:
27
+ app: Engine instance (Flask-based application)
28
+ telemetry_config: Telemetry configuration specifying endpoint and export options
29
+
30
+ Returns:
31
+ OpenTelemetry tracer instance if enabled, None otherwise
32
+
33
+ Raises:
34
+ ImportError: If OpenTelemetry packages are not installed when telemetry is enabled
35
+ ValueError: If telemetry is enabled but no exporters are configured
36
+ """
37
+ if not telemetry_config.enabled:
38
+ return None
39
+
40
+ # Reject telemetry enabled without exporters (creates overhead without benefit)
41
+ if not telemetry_config.endpoint and not telemetry_config.console_export:
42
+ raise ValueError(_MISSING_EXPORTERS_MSG)
43
+
44
+ # If already instrumented, return tracer without rebuilding provider/exporters
45
+ if app.telemetry_instrumented:
46
+ from opentelemetry import trace
47
+
48
+ return trace.get_tracer(__name__)
49
+
50
+ # Import OpenTelemetry modules (will raise ImportError if not installed)
51
+ from opentelemetry import trace
52
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
53
+ from opentelemetry.instrumentation.flask import FlaskInstrumentor
54
+ from opentelemetry.sdk.resources import Resource
55
+ from opentelemetry.sdk.trace import TracerProvider
56
+ from opentelemetry.sdk.trace.export import (
57
+ BatchSpanProcessor,
58
+ ConsoleSpanExporter,
59
+ SimpleSpanProcessor,
60
+ )
61
+ from opentelemetry.semconv.resource import ResourceAttributes
62
+
63
+ # Build resource attributes
64
+ service_name = app.config.get("APP_NAME", "platzky")
65
+ resource_attrs: dict[str, str] = {
66
+ ResourceAttributes.SERVICE_NAME: service_name,
67
+ }
68
+
69
+ # Auto-detect service version from package metadata
70
+ from importlib.metadata import PackageNotFoundError
71
+ from importlib.metadata import version as get_version
72
+
73
+ try:
74
+ resource_attrs[ResourceAttributes.SERVICE_VERSION] = get_version("platzky")
75
+ except PackageNotFoundError:
76
+ pass # Version not available
77
+
78
+ if telemetry_config.deployment_environment:
79
+ resource_attrs[ResourceAttributes.DEPLOYMENT_ENVIRONMENT] = (
80
+ telemetry_config.deployment_environment
81
+ )
82
+
83
+ # Add instance ID (user-provided or auto-generated)
84
+ if telemetry_config.service_instance_id:
85
+ resource_attrs[ResourceAttributes.SERVICE_INSTANCE_ID] = (
86
+ telemetry_config.service_instance_id
87
+ )
88
+ else:
89
+ # Generate unique instance ID: hostname + short UUID
90
+ hostname = socket.gethostname()
91
+ instance_uuid = str(uuid.uuid4())[:8]
92
+ resource_attrs[ResourceAttributes.SERVICE_INSTANCE_ID] = f"{hostname}-{instance_uuid}"
93
+
94
+ resource = Resource.create(resource_attrs)
95
+ provider = TracerProvider(resource=resource)
96
+
97
+ # Configure exporter based on endpoint
98
+ if telemetry_config.endpoint:
99
+ exporter = OTLPSpanExporter(
100
+ endpoint=telemetry_config.endpoint, timeout=telemetry_config.timeout
101
+ )
102
+ provider.add_span_processor(BatchSpanProcessor(exporter))
103
+
104
+ # Optional console export
105
+ if telemetry_config.console_export:
106
+ provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
107
+
108
+ trace.set_tracer_provider(provider)
109
+ FlaskInstrumentor().instrument_app(app)
110
+ app.telemetry_instrumented = True
111
+
112
+ # Optionally flush spans after each request (may impact latency)
113
+ if telemetry_config.flush_on_request:
114
+
115
+ @app.teardown_appcontext
116
+ def flush_telemetry(_exc: Optional[BaseException] = None) -> None:
117
+ """Flush pending spans after request completion."""
118
+ provider.force_flush(timeout_millis=telemetry_config.flush_timeout_ms)
119
+
120
+ # Shutdown provider once at process exit
121
+ atexit.register(provider.shutdown)
122
+
123
+ return trace.get_tracer(__name__)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "platzky"
3
- version = "0.4.1"
3
+ version = "1.0.0"
4
4
  description = "Not only blog engine"
5
5
  authors = []
6
6
  license = "MIT"
@@ -21,6 +21,28 @@ pydantic = "^2.7.1"
21
21
  deprecation = "^2.1.0"
22
22
  pygithub = "^2.6.1"
23
23
  pymongo = "^4.7.0"
24
+ sphinx = {version = "^8.0.0", optional = true}
25
+ sphinx-rtd-theme = {version = "^3.0.0", optional = true}
26
+ tomli = {version = "^2.0.0", optional = true}
27
+ myst-parser = {version = "^4.0.0", optional = true}
28
+ opentelemetry-api = {version = "^1.27.0", optional = true}
29
+ opentelemetry-sdk = {version = "^1.27.0", optional = true}
30
+ opentelemetry-instrumentation-flask = {version = "^0.48b0", optional = true}
31
+ opentelemetry-exporter-otlp-proto-grpc = {version = "^1.27.0", optional = true}
32
+
33
+ [tool.poetry.extras]
34
+ docs = [
35
+ "sphinx",
36
+ "sphinx-rtd-theme",
37
+ "myst-parser",
38
+ "tomli"
39
+ ]
40
+ telemetry = [
41
+ "opentelemetry-api",
42
+ "opentelemetry-sdk",
43
+ "opentelemetry-instrumentation-flask",
44
+ "opentelemetry-exporter-otlp-proto-grpc"
45
+ ]
24
46
 
25
47
  [tool.poetry.group.dev.dependencies]
26
48
  pytest = "^8.2.1"
@@ -31,8 +53,12 @@ black = "^24.8.0"
31
53
  ruff = "^0.4.4"
32
54
  pyright = "^1.1.364"
33
55
  beautifulsoup4 = "^4.12.3"
34
- python-semantic-release = "^9.0.0"
35
-
56
+ python-semantic-release = "^9.8.0"
57
+ interrogate = "^1.7.0"
58
+ opentelemetry-api = "^1.27.0"
59
+ opentelemetry-sdk = "^1.27.0"
60
+ opentelemetry-instrumentation-flask = "^0.48b0"
61
+ opentelemetry-exporter-otlp-proto-grpc = "^1.27.0"
36
62
 
37
63
  [build-system]
38
64
  requires = ["poetry-core"]
@@ -42,18 +68,19 @@ build-backend = "poetry.core.masonry.api"
42
68
  omit = [
43
69
  "tests/*",
44
70
  "*/__init__.py"
45
- ]
71
+ ]
46
72
 
47
73
  [tool.coverage.report]
48
74
  exclude_lines = [
49
75
  "@abstractmethod",
50
76
  "@abc.abstractmethod"
51
- ]
77
+ ]
78
+ fail_under = 70
79
+ show_missing = true
52
80
 
53
81
  [tool.pyright]
54
82
  pythonVersion = "3.10"
55
83
  pythonPlatform = "All"
56
-
57
84
  typeCheckingMode = "strict"
58
85
  reportMissingImports = true
59
86
  reportMissingTypeStubs = false
@@ -109,3 +136,18 @@ prerelease = false
109
136
  [tool.semantic_release.publish]
110
137
  dist_glob_patterns = ["dist/*"]
111
138
  upload_to_vcs_release = true
139
+
140
+ [tool.semantic_release.remote]
141
+ type = "github"
142
+
143
+ [tool.semantic_release.remote.token]
144
+ env = "GH_TOKEN"
145
+
146
+ [tool.interrogate]
147
+ fail-under = 20
148
+ ignore-init-method = true
149
+ ignore-init-module = true
150
+ ignore-magic = true
151
+ ignore-private = true
152
+ ignore-semiprivate = false
153
+ exclude = ["tests", "docs"]
@@ -1,69 +0,0 @@
1
- import sys
2
- import typing as t
3
-
4
- import yaml
5
- from pydantic import BaseModel, ConfigDict, Field
6
-
7
- from .db.db import DBConfig
8
- from .db.db_loader import get_db_module
9
-
10
-
11
- class StrictBaseModel(BaseModel):
12
- model_config = ConfigDict(frozen=True)
13
-
14
-
15
- class LanguageConfig(StrictBaseModel):
16
- name: str = Field(alias="name")
17
- flag: str = Field(alias="flag")
18
- country: str = Field(alias="country")
19
- domain: t.Optional[str] = Field(default=None, alias="domain")
20
-
21
-
22
- Languages = dict[str, LanguageConfig]
23
- LanguagesMapping = t.Mapping[str, t.Mapping[str, str]]
24
-
25
-
26
- def languages_dict(languages: Languages) -> LanguagesMapping:
27
- return {name: lang.model_dump() for name, lang in languages.items()}
28
-
29
-
30
- class Config(StrictBaseModel):
31
- app_name: str = Field(alias="APP_NAME")
32
- secret_key: str = Field(alias="SECRET_KEY")
33
- db: DBConfig = Field(alias="DB")
34
- use_www: bool = Field(default=True, alias="USE_WWW")
35
- seo_prefix: str = Field(default="/", alias="SEO_PREFIX")
36
- blog_prefix: str = Field(default="/", alias="BLOG_PREFIX")
37
- languages: Languages = Field(default_factory=dict, alias="LANGUAGES")
38
- domain_to_lang: dict[str, str] = Field(default_factory=dict, alias="DOMAIN_TO_LANG")
39
- translation_directories: list[str] = Field(
40
- default_factory=list,
41
- alias="TRANSLATION_DIRECTORIES",
42
- )
43
- debug: bool = Field(default=False, alias="DEBUG")
44
- testing: bool = Field(default=False, alias="TESTING")
45
- feature_flags: t.Optional[dict[str, bool]] = Field(default_factory=dict, alias="FEATURE_FLAGS")
46
-
47
- @classmethod
48
- def model_validate(
49
- cls,
50
- obj: t.Any,
51
- *,
52
- strict: bool | None = None,
53
- from_attributes: bool | None = None,
54
- context: dict[str, t.Any] | None = None,
55
- ) -> "Config":
56
- db_cfg_type = get_db_module(obj["DB"]["TYPE"]).db_config_type()
57
- obj["DB"] = db_cfg_type.model_validate(obj["DB"])
58
- return super().model_validate(
59
- obj, strict=strict, from_attributes=from_attributes, context=context
60
- )
61
-
62
- @classmethod
63
- def parse_yaml(cls, path: str) -> "Config":
64
- try:
65
- with open(path, "r") as f:
66
- return cls.model_validate(yaml.safe_load(f))
67
- except FileNotFoundError:
68
- print(f"Config file not found: {path}", file=sys.stderr)
69
- raise SystemExit(1)
@@ -1,71 +0,0 @@
1
- import os
2
- from typing import List
3
-
4
- from flask import Flask, request, session
5
- from flask_babel import Babel
6
-
7
- from platzky.config import Config
8
- from platzky.models import CmsModule
9
-
10
-
11
- class Engine(Flask):
12
- def __init__(self, config: Config, db, import_name):
13
- super().__init__(import_name)
14
- self.config.from_mapping(config.model_dump(by_alias=True))
15
- self.db = db
16
- self.notifiers = []
17
- self.login_methods = []
18
- self.dynamic_body = ""
19
- self.dynamic_head = ""
20
- directory = os.path.dirname(os.path.realpath(__file__))
21
- locale_dir = os.path.join(directory, "locale")
22
- config.translation_directories.append(locale_dir)
23
- babel_translation_directories = ";".join(config.translation_directories)
24
- self.babel = Babel(
25
- self,
26
- locale_selector=self.get_locale,
27
- default_translation_directories=babel_translation_directories,
28
- )
29
-
30
- self.cms_modules: List[CmsModule] = []
31
- # TODO add plugins as CMS Module - all plugins should be visible from
32
- # admin page at least as configuration
33
-
34
- def notify(self, message: str):
35
- for notifier in self.notifiers:
36
- notifier(message)
37
-
38
- def add_notifier(self, notifier):
39
- self.notifiers.append(notifier)
40
-
41
- def add_cms_module(self, module: CmsModule):
42
- """Add a CMS module to the modules list."""
43
- self.cms_modules.append(module)
44
-
45
- # TODO login_method should be interface
46
- def add_login_method(self, login_method):
47
- self.login_methods.append(login_method)
48
-
49
- def add_dynamic_body(self, body: str):
50
- self.dynamic_body += body
51
-
52
- def add_dynamic_head(self, body: str):
53
- self.dynamic_head += body
54
-
55
- def get_locale(self) -> str:
56
- domain = request.headers.get("Host", "localhost")
57
- domain_to_lang = self.config.get("DOMAIN_TO_LANG")
58
-
59
- languages = self.config.get("LANGUAGES", {}).keys()
60
- backup_lang = session.get(
61
- "language",
62
- request.accept_languages.best_match(languages, "en"),
63
- )
64
-
65
- if domain_to_lang:
66
- lang = domain_to_lang.get(domain, backup_lang)
67
- else:
68
- lang = backup_lang
69
-
70
- session["language"] = lang
71
- return lang
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes