platzky 1.0.0__py3-none-any.whl → 1.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.
- platzky/admin/fake_login.py +2 -1
- platzky/blog/blog.py +57 -35
- platzky/config.py +2 -0
- platzky/db/db.py +2 -1
- platzky/db/json_db.py +14 -6
- platzky/engine.py +5 -4
- platzky/models.py +160 -24
- platzky/platzky.py +169 -23
- platzky/plugin/plugin.py +39 -3
- platzky/plugin/plugin_loader.py +105 -10
- platzky/seo/seo.py +1 -1
- platzky/telemetry.py +9 -0
- {platzky-1.0.0.dist-info → platzky-1.1.0.dist-info}/METADATA +2 -1
- {platzky-1.0.0.dist-info → platzky-1.1.0.dist-info}/RECORD +16 -16
- {platzky-1.0.0.dist-info → platzky-1.1.0.dist-info}/WHEEL +0 -0
- {platzky-1.0.0.dist-info → platzky-1.1.0.dist-info}/licenses/LICENSE +0 -0
platzky/admin/fake_login.py
CHANGED
|
@@ -6,7 +6,8 @@ environments as it bypasses proper authentication and authorization controls.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import os
|
|
9
|
-
from
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
10
11
|
|
|
11
12
|
from flask import Blueprint, flash, redirect, render_template_string, session, url_for
|
|
12
13
|
from flask_wtf import FlaskForm
|
platzky/blog/blog.py
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Callable
|
|
1
3
|
from os.path import dirname
|
|
4
|
+
from typing import Any
|
|
2
5
|
|
|
3
|
-
from flask import Blueprint, make_response, render_template, request
|
|
6
|
+
from flask import Blueprint, abort, make_response, render_template, request
|
|
4
7
|
from markupsafe import Markup
|
|
8
|
+
from werkzeug.exceptions import HTTPException
|
|
9
|
+
from werkzeug.wrappers import Response
|
|
5
10
|
|
|
6
11
|
from . import comment_form
|
|
7
12
|
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
8
15
|
|
|
9
16
|
def create_blog_blueprint(db, blog_prefix: str, locale_func):
|
|
10
17
|
url_prefix = blog_prefix
|
|
@@ -20,27 +27,35 @@ def create_blog_blueprint(db, blog_prefix: str, locale_func):
|
|
|
20
27
|
return Markup(text)
|
|
21
28
|
|
|
22
29
|
@blog.errorhandler(404)
|
|
23
|
-
def page_not_found(
|
|
30
|
+
def page_not_found(_e: HTTPException) -> tuple[str, int]:
|
|
31
|
+
"""Handle 404 Not Found errors in blog routes.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
_e: HTTPException object containing error details (unused)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Tuple of rendered 404 template and HTTP 404 status code
|
|
38
|
+
"""
|
|
24
39
|
return render_template("404.html", title="404"), 404
|
|
25
40
|
|
|
26
41
|
@blog.route("/", methods=["GET"])
|
|
27
|
-
def all_posts():
|
|
42
|
+
def all_posts() -> str:
|
|
28
43
|
lang = locale_func()
|
|
29
44
|
posts = db.get_all_posts(lang)
|
|
30
45
|
if not posts:
|
|
31
|
-
|
|
46
|
+
abort(404)
|
|
32
47
|
posts_sorted = sorted(posts, reverse=True)
|
|
33
48
|
return render_template("blog.html", posts=posts_sorted)
|
|
34
49
|
|
|
35
50
|
@blog.route("/feed", methods=["GET"])
|
|
36
|
-
def get_feed():
|
|
51
|
+
def get_feed() -> Response:
|
|
37
52
|
lang = locale_func()
|
|
38
53
|
response = make_response(render_template("feed.xml", posts=db.get_all_posts(lang)))
|
|
39
54
|
response.headers["Content-Type"] = "application/xml"
|
|
40
55
|
return response
|
|
41
56
|
|
|
42
57
|
@blog.route("/<post_slug>", methods=["POST"])
|
|
43
|
-
def post_comment(post_slug):
|
|
58
|
+
def post_comment(post_slug: str) -> str:
|
|
44
59
|
comment = request.form.to_dict()
|
|
45
60
|
db.add_comment(
|
|
46
61
|
post_slug=post_slug,
|
|
@@ -49,40 +64,47 @@ def create_blog_blueprint(db, blog_prefix: str, locale_func):
|
|
|
49
64
|
)
|
|
50
65
|
return get_post(post_slug=post_slug)
|
|
51
66
|
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
def _get_content_or_404(
|
|
68
|
+
getter_func: Callable[[str], Any],
|
|
69
|
+
slug: str,
|
|
70
|
+
) -> Any:
|
|
71
|
+
"""Helper to fetch content from database or abort with 404.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
getter_func: Database getter function (e.g., db.get_post, db.get_page)
|
|
75
|
+
slug: Content slug to fetch
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The fetched content object
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
HTTPException: 404 if content not found
|
|
82
|
+
"""
|
|
54
83
|
try:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
return getter_func(slug)
|
|
85
|
+
except ValueError as e:
|
|
86
|
+
logger.debug("Content not found for slug '%s': %s", slug, e)
|
|
87
|
+
abort(404)
|
|
88
|
+
|
|
89
|
+
@blog.route("/<post_slug>", methods=["GET"])
|
|
90
|
+
def get_post(post_slug: str) -> str:
|
|
91
|
+
post = _get_content_or_404(db.get_post, post_slug)
|
|
92
|
+
return render_template(
|
|
93
|
+
"post.html",
|
|
94
|
+
post=post,
|
|
95
|
+
post_slug=post_slug,
|
|
96
|
+
form=comment_form.CommentForm(),
|
|
97
|
+
comment_sent=request.args.get("comment_sent"),
|
|
98
|
+
)
|
|
67
99
|
|
|
68
100
|
@blog.route("/page/<path:page_slug>", methods=["GET"])
|
|
69
|
-
def get_page(
|
|
70
|
-
page_slug
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
page = db.get_page(page_slug)
|
|
74
|
-
if cover_image := page.coverImage:
|
|
75
|
-
cover_image_url = cover_image.url
|
|
76
|
-
else:
|
|
77
|
-
cover_image_url = None
|
|
78
|
-
return render_template("page.html", page=page, cover_image=cover_image_url)
|
|
79
|
-
except ValueError:
|
|
80
|
-
return page_not_found("no page with slug {page_slug}")
|
|
81
|
-
except Exception as e:
|
|
82
|
-
return page_not_found(str(e))
|
|
101
|
+
def get_page(page_slug: str) -> str:
|
|
102
|
+
page = _get_content_or_404(db.get_page, page_slug)
|
|
103
|
+
cover_image_url = page.coverImage.url if page.coverImage.url else None
|
|
104
|
+
return render_template("page.html", page=page, cover_image=cover_image_url)
|
|
83
105
|
|
|
84
106
|
@blog.route("/tag/<path:tag>", methods=["GET"])
|
|
85
|
-
def get_posts_from_tag(tag):
|
|
107
|
+
def get_posts_from_tag(tag: str) -> str:
|
|
86
108
|
lang = locale_func()
|
|
87
109
|
posts = db.get_posts_by_tag(tag, lang)
|
|
88
110
|
return render_template("blog.html", posts=posts, subtitle=f" - tag: {tag}")
|
platzky/config.py
CHANGED
|
@@ -75,6 +75,7 @@ class TelemetryConfig(StrictBaseModel):
|
|
|
75
75
|
service_instance_id: Service instance ID (auto-generated if not provided)
|
|
76
76
|
flush_on_request: Flush spans after each request (default: True, may impact latency)
|
|
77
77
|
flush_timeout_ms: Timeout in milliseconds for per-request flush (default: 5000)
|
|
78
|
+
instrument_logging: Enable automatic logging instrumentation (default: True)
|
|
78
79
|
"""
|
|
79
80
|
|
|
80
81
|
enabled: bool = Field(default=False, alias="enabled")
|
|
@@ -85,6 +86,7 @@ class TelemetryConfig(StrictBaseModel):
|
|
|
85
86
|
service_instance_id: t.Optional[str] = Field(default=None, alias="service_instance_id")
|
|
86
87
|
flush_on_request: bool = Field(default=True, alias="flush_on_request")
|
|
87
88
|
flush_timeout_ms: int = Field(default=5000, alias="flush_timeout_ms", gt=0)
|
|
89
|
+
instrument_logging: bool = Field(default=True, alias="instrument_logging")
|
|
88
90
|
|
|
89
91
|
@field_validator("endpoint")
|
|
90
92
|
@classmethod
|
platzky/db/db.py
CHANGED
platzky/db/json_db.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import datetime
|
|
2
|
-
from typing import Any
|
|
2
|
+
from typing import Any
|
|
3
3
|
|
|
4
4
|
from pydantic import Field
|
|
5
5
|
|
|
@@ -12,7 +12,7 @@ def db_config_type():
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class JsonDbConfig(DBConfig):
|
|
15
|
-
data:
|
|
15
|
+
data: dict[str, Any] = Field(alias="DATA")
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def get_db(config):
|
|
@@ -30,9 +30,9 @@ def db_from_config(config: JsonDbConfig):
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
class Json(DB):
|
|
33
|
-
def __init__(self, data:
|
|
33
|
+
def __init__(self, data: dict[str, Any]):
|
|
34
34
|
super().__init__()
|
|
35
|
-
self.data:
|
|
35
|
+
self.data: dict[str, Any] = data
|
|
36
36
|
self.module_name = "json_db"
|
|
37
37
|
self.db_name = "JsonDb"
|
|
38
38
|
|
|
@@ -62,7 +62,10 @@ class Json(DB):
|
|
|
62
62
|
list_of_pages = (
|
|
63
63
|
page for page in self._get_site_content().get("pages") if page["slug"] == slug
|
|
64
64
|
)
|
|
65
|
-
|
|
65
|
+
wanted_page = next(list_of_pages, None)
|
|
66
|
+
if wanted_page is None:
|
|
67
|
+
raise ValueError(f"Page with slug {slug} not found")
|
|
68
|
+
page = Post.model_validate(wanted_page)
|
|
66
69
|
return page
|
|
67
70
|
|
|
68
71
|
def get_menu_items_in_lang(self, lang) -> list[MenuItem]:
|
|
@@ -101,10 +104,15 @@ class Json(DB):
|
|
|
101
104
|
return self._get_site_content().get("secondary_color", "navy")
|
|
102
105
|
|
|
103
106
|
def add_comment(self, author_name, comment, post_slug):
|
|
107
|
+
# Store dates in UTC with timezone info for consistency with MongoDB backend
|
|
108
|
+
# This ensures accurate time delta calculations regardless of server timezone
|
|
109
|
+
# Legacy dates without timezone info are still supported for backward compatibility
|
|
110
|
+
now_utc = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
|
111
|
+
|
|
104
112
|
comment = {
|
|
105
113
|
"author": str(author_name),
|
|
106
114
|
"comment": str(comment),
|
|
107
|
-
"date":
|
|
115
|
+
"date": now_utc,
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
post_index = next(
|
platzky/engine.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
|
+
from collections.abc import Callable
|
|
2
3
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
|
3
|
-
from typing import Any
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from flask import Blueprint, Flask, jsonify, make_response, request, session
|
|
6
7
|
from flask_babel import Babel
|
|
@@ -18,7 +19,7 @@ class Engine(Flask):
|
|
|
18
19
|
self.login_methods = []
|
|
19
20
|
self.dynamic_body = ""
|
|
20
21
|
self.dynamic_head = ""
|
|
21
|
-
self.health_checks:
|
|
22
|
+
self.health_checks: list[tuple[str, Callable[[], None]]] = []
|
|
22
23
|
self.telemetry_instrumented: bool = False
|
|
23
24
|
directory = os.path.dirname(os.path.realpath(__file__))
|
|
24
25
|
locale_dir = os.path.join(directory, "locale")
|
|
@@ -31,7 +32,7 @@ class Engine(Flask):
|
|
|
31
32
|
)
|
|
32
33
|
self._register_default_health_endpoints()
|
|
33
34
|
|
|
34
|
-
self.cms_modules:
|
|
35
|
+
self.cms_modules: list[CmsModule] = []
|
|
35
36
|
# TODO add plugins as CMS Module - all plugins should be visible from
|
|
36
37
|
# admin page at least as configuration
|
|
37
38
|
|
|
@@ -94,7 +95,7 @@ class Engine(Flask):
|
|
|
94
95
|
@health_bp.route("/health/readiness")
|
|
95
96
|
def readiness():
|
|
96
97
|
"""Readiness check - can the app serve traffic?"""
|
|
97
|
-
health_status:
|
|
98
|
+
health_status: dict[str, Any] = {"status": "ready", "checks": {}}
|
|
98
99
|
status_code = 200
|
|
99
100
|
|
|
100
101
|
executor = ThreadPoolExecutor(max_workers=1)
|
platzky/models.py
CHANGED
|
@@ -1,7 +1,87 @@
|
|
|
1
1
|
import datetime
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import Annotated
|
|
2
4
|
|
|
3
5
|
import humanize
|
|
4
|
-
from pydantic import BaseModel
|
|
6
|
+
from pydantic import BaseModel, BeforeValidator, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _parse_date_string(v: str | datetime.datetime) -> datetime.datetime:
|
|
10
|
+
"""Parse date string to datetime for backward compatibility.
|
|
11
|
+
|
|
12
|
+
Handles string dates in various ISO 8601 formats for backward compatibility.
|
|
13
|
+
Emits deprecation warning when parsing strings.
|
|
14
|
+
|
|
15
|
+
In version 2.0.0, only datetime objects will be accepted.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
v: Either a datetime object or an ISO 8601 date string
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Timezone-aware datetime object
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: If the date string cannot be parsed
|
|
25
|
+
"""
|
|
26
|
+
if isinstance(v, datetime.datetime):
|
|
27
|
+
# If already a datetime object, ensure it's timezone-aware
|
|
28
|
+
if v.tzinfo is None:
|
|
29
|
+
# Naive datetime - make timezone-aware using UTC
|
|
30
|
+
return v.replace(tzinfo=datetime.timezone.utc)
|
|
31
|
+
return v
|
|
32
|
+
|
|
33
|
+
# v must be a string (based on type annotation)
|
|
34
|
+
# Emit deprecation warning for string dates
|
|
35
|
+
warnings.warn(
|
|
36
|
+
f"Passing date as string ('{v}') is deprecated. "
|
|
37
|
+
"Please use datetime objects instead. "
|
|
38
|
+
"String support will be removed in version 2.0.0.",
|
|
39
|
+
DeprecationWarning,
|
|
40
|
+
stacklevel=2,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Check for timezone in the original string (before any manipulation)
|
|
44
|
+
# Check for: +HH:MM, -HH:MM, or Z suffix
|
|
45
|
+
time_part = v.split("T")[-1] if "T" in v else ""
|
|
46
|
+
has_timezone = (
|
|
47
|
+
v.endswith("Z")
|
|
48
|
+
or "+" in time_part
|
|
49
|
+
or ("-" in time_part and ":" in time_part.split("-")[-1])
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Normalize 'Z' suffix to '+00:00' for fromisoformat
|
|
53
|
+
normalized = v.replace("Z", "+00:00") if v.endswith("Z") else v
|
|
54
|
+
|
|
55
|
+
if has_timezone:
|
|
56
|
+
# Parse timezone-aware datetime (handles microseconds automatically)
|
|
57
|
+
return datetime.datetime.fromisoformat(normalized)
|
|
58
|
+
else:
|
|
59
|
+
# Legacy format: naive datetime - make timezone-aware using UTC
|
|
60
|
+
warnings.warn(
|
|
61
|
+
f"Naive datetime '{v}' interpreted as UTC. "
|
|
62
|
+
"Explicitly specify timezone in future versions for clarity.",
|
|
63
|
+
DeprecationWarning,
|
|
64
|
+
stacklevel=2,
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
parsed = datetime.datetime.fromisoformat(normalized)
|
|
68
|
+
return parsed.replace(tzinfo=datetime.timezone.utc)
|
|
69
|
+
except ValueError:
|
|
70
|
+
# Fallback: date-only format
|
|
71
|
+
parsed_date = datetime.date.fromisoformat(normalized)
|
|
72
|
+
return datetime.datetime.combine(
|
|
73
|
+
parsed_date, datetime.time.min, tzinfo=datetime.timezone.utc
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Type alias for datetime fields that accept strings for backward compatibility
|
|
78
|
+
# Input: str | datetime.datetime
|
|
79
|
+
# Output (after validation): datetime.datetime
|
|
80
|
+
DateTimeField = Annotated[
|
|
81
|
+
datetime.datetime,
|
|
82
|
+
BeforeValidator(_parse_date_string),
|
|
83
|
+
# This allows str at the type-checker level while ensuring datetime after validation
|
|
84
|
+
]
|
|
5
85
|
|
|
6
86
|
|
|
7
87
|
class CmsModule(BaseModel):
|
|
@@ -21,28 +101,73 @@ class CmsModuleGroup(CmsModule):
|
|
|
21
101
|
|
|
22
102
|
|
|
23
103
|
class Image(BaseModel):
|
|
104
|
+
"""Represents an image with URL and alternate text.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
url: URL path to the image resource
|
|
108
|
+
alternateText: Descriptive text for accessibility and SEO
|
|
109
|
+
"""
|
|
110
|
+
|
|
24
111
|
url: str = ""
|
|
25
112
|
alternateText: str = ""
|
|
26
113
|
|
|
27
114
|
|
|
28
115
|
class MenuItem(BaseModel):
|
|
116
|
+
"""Represents a navigation menu item.
|
|
117
|
+
|
|
118
|
+
Attributes:
|
|
119
|
+
name: Display name of the menu item
|
|
120
|
+
url: Target URL for the menu item link
|
|
121
|
+
"""
|
|
122
|
+
|
|
29
123
|
name: str
|
|
30
124
|
url: str
|
|
31
125
|
|
|
32
126
|
|
|
33
127
|
class Comment(BaseModel):
|
|
128
|
+
"""Represents a user comment on a blog post or page.
|
|
129
|
+
|
|
130
|
+
Attributes:
|
|
131
|
+
author: Name of the comment author
|
|
132
|
+
comment: The comment text content
|
|
133
|
+
date: Datetime when the comment was posted (timezone-aware recommended)
|
|
134
|
+
"""
|
|
135
|
+
|
|
34
136
|
author: str
|
|
35
137
|
comment: str
|
|
36
|
-
date:
|
|
138
|
+
date: DateTimeField
|
|
37
139
|
|
|
38
140
|
@property
|
|
39
141
|
def time_delta(self) -> str:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
142
|
+
"""Calculate human-readable time since the comment was posted.
|
|
143
|
+
|
|
144
|
+
Uses timezone-aware datetimes to ensure accurate time delta calculations.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Human-friendly time description (e.g., "2 hours ago", "3 days ago")
|
|
148
|
+
"""
|
|
149
|
+
# self.date is already a datetime object (parsed by field_validator)
|
|
150
|
+
# Always use timezone-aware datetime for consistency
|
|
151
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
152
|
+
return humanize.naturaltime(now - self.date)
|
|
43
153
|
|
|
44
154
|
|
|
45
155
|
class Post(BaseModel):
|
|
156
|
+
"""Represents a blog post with metadata, content, and comments.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
author: Name of the post author
|
|
160
|
+
slug: URL-friendly unique identifier for the post
|
|
161
|
+
title: Post title
|
|
162
|
+
contentInMarkdown: Post content in Markdown format
|
|
163
|
+
comments: List of comments on this post
|
|
164
|
+
excerpt: Short summary or preview of the post
|
|
165
|
+
tags: List of tags for categorization
|
|
166
|
+
language: Language code for the post content
|
|
167
|
+
coverImage: Cover image for the post
|
|
168
|
+
date: Datetime when the post was published (timezone-aware recommended)
|
|
169
|
+
"""
|
|
170
|
+
|
|
46
171
|
author: str
|
|
47
172
|
slug: str
|
|
48
173
|
title: str
|
|
@@ -52,30 +177,41 @@ class Post(BaseModel):
|
|
|
52
177
|
tags: list[str]
|
|
53
178
|
language: str
|
|
54
179
|
coverImage: Image
|
|
55
|
-
date:
|
|
180
|
+
date: DateTimeField
|
|
181
|
+
|
|
182
|
+
def __lt__(self, other: object) -> bool:
|
|
183
|
+
"""Compare posts by date for sorting.
|
|
184
|
+
|
|
185
|
+
Uses datetime comparison to ensure robust and correct ordering.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
other: Another Post instance to compare against
|
|
56
189
|
|
|
57
|
-
|
|
190
|
+
Returns:
|
|
191
|
+
True if this post's date is earlier than the other post's date,
|
|
192
|
+
or NotImplemented if comparing with a non-Post object
|
|
193
|
+
"""
|
|
58
194
|
if isinstance(other, Post):
|
|
59
195
|
return self.date < other.date
|
|
60
|
-
|
|
196
|
+
return NotImplemented
|
|
61
197
|
|
|
62
198
|
|
|
63
|
-
Page = Post
|
|
199
|
+
Page = Post # Page is an alias for Post (static pages use the same structure)
|
|
64
200
|
|
|
65
201
|
|
|
66
202
|
class Color(BaseModel):
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
203
|
+
"""Represents an RGBA color value.
|
|
204
|
+
|
|
205
|
+
Attributes:
|
|
206
|
+
r: Red component (0-255)
|
|
207
|
+
g: Green component (0-255)
|
|
208
|
+
b: Blue component (0-255)
|
|
209
|
+
a: Alpha/transparency component (0-255, where 255 is fully opaque)
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
r: int = Field(default=0, ge=0, le=255, description="Red component (0-255)")
|
|
213
|
+
g: int = Field(default=0, ge=0, le=255, description="Green component (0-255)")
|
|
214
|
+
b: int = Field(default=0, ge=0, le=255, description="Blue component (0-255)")
|
|
215
|
+
a: int = Field(
|
|
216
|
+
default=255, ge=0, le=255, description="Alpha component (0-255, where 255 is fully opaque)"
|
|
217
|
+
)
|
platzky/platzky.py
CHANGED
|
@@ -4,6 +4,8 @@ import urllib.parse
|
|
|
4
4
|
from flask import redirect, render_template, request, session
|
|
5
5
|
from flask_minify import Minify
|
|
6
6
|
from flask_wtf import CSRFProtect
|
|
7
|
+
from werkzeug.exceptions import HTTPException
|
|
8
|
+
from werkzeug.wrappers import Response
|
|
7
9
|
|
|
8
10
|
from platzky.admin import admin
|
|
9
11
|
from platzky.blog import blog
|
|
@@ -11,6 +13,7 @@ from platzky.config import (
|
|
|
11
13
|
Config,
|
|
12
14
|
languages_dict,
|
|
13
15
|
)
|
|
16
|
+
from platzky.db.db import DB
|
|
14
17
|
from platzky.db.db_loader import get_db
|
|
15
18
|
from platzky.engine import Engine
|
|
16
19
|
from platzky.plugin.plugin_loader import plugify
|
|
@@ -24,38 +27,131 @@ _MISSING_OTEL_MSG = (
|
|
|
24
27
|
)
|
|
25
28
|
|
|
26
29
|
|
|
27
|
-
def
|
|
30
|
+
def _url_encode(x: str) -> str:
|
|
31
|
+
"""URL-encode a string for safe use in URLs.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
x: String to encode
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
URL-encoded string with all characters except safe ones escaped
|
|
38
|
+
"""
|
|
39
|
+
return urllib.parse.quote(x, safe="")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_language_domain(config: Config, lang: str) -> t.Optional[str]:
|
|
43
|
+
"""Get the domain associated with a language.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
config: Application configuration
|
|
47
|
+
lang: Language code to look up
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Domain string if language has a dedicated domain, None otherwise
|
|
51
|
+
"""
|
|
52
|
+
lang_cfg = config.languages.get(lang)
|
|
53
|
+
if lang_cfg is None:
|
|
54
|
+
return None
|
|
55
|
+
return lang_cfg.domain
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_safe_redirect_url(referrer: t.Optional[str], current_host: str) -> str:
|
|
59
|
+
"""Get a safe redirect URL by validating the referrer.
|
|
60
|
+
|
|
61
|
+
Prevents open redirect vulnerabilities by only allowing same-host redirects.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
referrer: The HTTP referrer header value
|
|
65
|
+
current_host: The current request host
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The referrer URL if safe, otherwise "/"
|
|
69
|
+
"""
|
|
70
|
+
if not referrer:
|
|
71
|
+
return "/"
|
|
72
|
+
|
|
73
|
+
referrer_parsed = urllib.parse.urlparse(referrer)
|
|
74
|
+
# Only redirect to referrer if it's from the same host
|
|
75
|
+
if referrer_parsed.netloc == current_host:
|
|
76
|
+
return referrer
|
|
77
|
+
return "/"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_engine(config: Config, db: DB) -> Engine:
|
|
81
|
+
"""Create and configure a Platzky Engine instance.
|
|
82
|
+
|
|
83
|
+
Sets up the core application with database connection, request handlers,
|
|
84
|
+
route definitions, and context processors for template rendering.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
config: Application configuration object
|
|
88
|
+
db: Database instance for data persistence
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Configured Engine instance with plugins loaded
|
|
92
|
+
"""
|
|
28
93
|
app = Engine(config, db, __name__)
|
|
29
94
|
|
|
30
95
|
@app.before_request
|
|
31
|
-
def handle_www_redirection():
|
|
96
|
+
def handle_www_redirection() -> t.Optional[Response]:
|
|
97
|
+
"""Handle WWW subdomain redirection based on configuration.
|
|
98
|
+
|
|
99
|
+
Redirects requests to/from www subdomain based on config.use_www setting.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Redirect response if redirection is needed, None otherwise
|
|
103
|
+
"""
|
|
32
104
|
if config.use_www:
|
|
33
105
|
return redirect_nonwww_to_www()
|
|
34
|
-
|
|
35
|
-
return redirect_www_to_nonwww()
|
|
36
|
-
|
|
37
|
-
def get_langs_domain(lang: str) -> t.Optional[str]:
|
|
38
|
-
lang_cfg = config.languages.get(lang)
|
|
39
|
-
if lang_cfg is None:
|
|
40
|
-
return None
|
|
41
|
-
return lang_cfg.domain
|
|
106
|
+
return redirect_www_to_nonwww()
|
|
42
107
|
|
|
43
108
|
@app.route("/lang/<string:lang>", methods=["GET"])
|
|
44
|
-
def change_language(lang):
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
109
|
+
def change_language(lang: str) -> Response | tuple[str, int]:
|
|
110
|
+
"""Change the user's language preference.
|
|
111
|
+
|
|
112
|
+
If the language has a dedicated domain, redirects to that domain.
|
|
113
|
+
Otherwise, sets the language in the session and returns to the referrer.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
lang: Language code to switch to
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Redirect response to the language domain or referrer page, or 404 if invalid
|
|
120
|
+
"""
|
|
121
|
+
# Only allow configured languages
|
|
122
|
+
if lang not in config.languages:
|
|
123
|
+
return render_template("404.html", title="404"), 404
|
|
124
|
+
|
|
125
|
+
if new_domain := _get_language_domain(config, lang):
|
|
126
|
+
return redirect(f"{request.scheme}://{new_domain}", code=302)
|
|
127
|
+
|
|
128
|
+
session["language"] = lang
|
|
129
|
+
redirect_url = _get_safe_redirect_url(request.referrer, request.host)
|
|
130
|
+
return redirect(redirect_url)
|
|
50
131
|
|
|
51
132
|
def url_link(x: str) -> str:
|
|
52
|
-
|
|
133
|
+
"""URL-encode a string for safe use in URLs.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
x: String to encode
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
URL-encoded string with all characters except safe ones escaped
|
|
140
|
+
"""
|
|
141
|
+
return _url_encode(x)
|
|
53
142
|
|
|
54
143
|
@app.context_processor
|
|
55
|
-
def utils():
|
|
144
|
+
def utils() -> dict[str, t.Any]:
|
|
145
|
+
"""Provide utility variables and functions to all templates.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Dictionary of template context variables including app metadata,
|
|
149
|
+
language settings, styling configuration, and helper functions
|
|
150
|
+
"""
|
|
56
151
|
locale = app.get_locale()
|
|
57
|
-
|
|
58
|
-
|
|
152
|
+
lang = config.languages.get(locale)
|
|
153
|
+
flag = lang.flag if lang is not None else ""
|
|
154
|
+
country = lang.country if lang is not None else ""
|
|
59
155
|
return {
|
|
60
156
|
"app_name": config.app_name,
|
|
61
157
|
"app_description": app.db.get_app_description(locale) or config.app_name,
|
|
@@ -73,21 +169,55 @@ def create_engine(config: Config, db) -> Engine:
|
|
|
73
169
|
}
|
|
74
170
|
|
|
75
171
|
@app.context_processor
|
|
76
|
-
def dynamic_body():
|
|
172
|
+
def dynamic_body() -> dict[str, str]:
|
|
173
|
+
"""Provide dynamic body content to all templates.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Dictionary with dynamic_body content for injection into page body
|
|
177
|
+
"""
|
|
77
178
|
return {"dynamic_body": app.dynamic_body}
|
|
78
179
|
|
|
79
180
|
@app.context_processor
|
|
80
|
-
def dynamic_head():
|
|
181
|
+
def dynamic_head() -> dict[str, str]:
|
|
182
|
+
"""Provide dynamic head content to all templates.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Dictionary with dynamic_head content for injection into page head
|
|
186
|
+
"""
|
|
81
187
|
return {"dynamic_head": app.dynamic_head}
|
|
82
188
|
|
|
83
189
|
@app.errorhandler(404)
|
|
84
|
-
def page_not_found(
|
|
190
|
+
def page_not_found(_e: HTTPException) -> tuple[str, int]:
|
|
191
|
+
"""Handle 404 Not Found errors.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
_e: HTTPException object containing error details (unused)
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Tuple of rendered 404 template and HTTP 404 status code
|
|
198
|
+
"""
|
|
85
199
|
return render_template("404.html", title="404"), 404
|
|
86
200
|
|
|
87
201
|
return plugify(app)
|
|
88
202
|
|
|
89
203
|
|
|
90
204
|
def create_app_from_config(config: Config) -> Engine:
|
|
205
|
+
"""Create a fully configured Platzky application from a Config object.
|
|
206
|
+
|
|
207
|
+
Initializes the database, creates the engine, sets up telemetry (if enabled),
|
|
208
|
+
registers blueprints (admin, blog, SEO), and configures minification and CSRF
|
|
209
|
+
protection.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
config: Application configuration object
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Fully configured Engine instance ready to serve requests
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ImportError: If telemetry is enabled but OpenTelemetry packages are not installed
|
|
219
|
+
ValueError: If telemetry configuration is invalid
|
|
220
|
+
"""
|
|
91
221
|
db = get_db(config.db)
|
|
92
222
|
engine = create_engine(config, db)
|
|
93
223
|
|
|
@@ -133,5 +263,21 @@ def create_app_from_config(config: Config) -> Engine:
|
|
|
133
263
|
|
|
134
264
|
|
|
135
265
|
def create_app(config_path: str) -> Engine:
|
|
266
|
+
"""Create a Platzky application from a YAML configuration file.
|
|
267
|
+
|
|
268
|
+
Convenience function that loads configuration from a YAML file and
|
|
269
|
+
creates the application.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
config_path: Path to the YAML configuration file
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Fully configured Engine instance ready to serve requests
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
FileNotFoundError: If the configuration file doesn't exist
|
|
279
|
+
yaml.YAMLError: If the configuration file contains invalid YAML
|
|
280
|
+
ValidationError: If the configuration doesn't match the expected schema
|
|
281
|
+
"""
|
|
136
282
|
config = Config.parse_yaml(config_path)
|
|
137
283
|
return create_app_from_config(config)
|
platzky/plugin/plugin.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import inspect
|
|
1
2
|
import logging
|
|
3
|
+
import os
|
|
4
|
+
import types
|
|
2
5
|
from abc import ABC, abstractmethod
|
|
3
|
-
from typing import Any,
|
|
6
|
+
from typing import Any, Generic, Optional, TypeVar
|
|
4
7
|
|
|
5
8
|
from pydantic import BaseModel, ConfigDict
|
|
6
9
|
|
|
@@ -39,17 +42,50 @@ class PluginBase(Generic[T], ABC):
|
|
|
39
42
|
Plugin developers must extend this class to implement their plugins.
|
|
40
43
|
"""
|
|
41
44
|
|
|
45
|
+
@staticmethod
|
|
46
|
+
def get_locale_dir_from_module(plugin_module: types.ModuleType) -> Optional[str]:
|
|
47
|
+
"""Get plugin locale directory from a module.
|
|
48
|
+
|
|
49
|
+
Encapsulates the knowledge of how plugins organize their locale directories.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
plugin_module: The plugin module
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Path to the locale directory if it exists, None otherwise
|
|
56
|
+
"""
|
|
57
|
+
if not hasattr(plugin_module, "__file__") or plugin_module.__file__ is None:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
# Use realpath to resolve symlinks and get canonical path
|
|
61
|
+
plugin_dir = os.path.dirname(os.path.realpath(plugin_module.__file__))
|
|
62
|
+
locale_dir = os.path.join(plugin_dir, "locale")
|
|
63
|
+
|
|
64
|
+
return locale_dir if os.path.isdir(locale_dir) else None
|
|
65
|
+
|
|
42
66
|
@classmethod
|
|
43
|
-
def get_config_model(cls) ->
|
|
67
|
+
def get_config_model(cls) -> type[PluginBaseConfig]:
|
|
44
68
|
return PluginBaseConfig
|
|
45
69
|
|
|
46
|
-
def __init__(self, config:
|
|
70
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
47
71
|
try:
|
|
48
72
|
config_class = self.get_config_model()
|
|
49
73
|
self.config = config_class.model_validate(config)
|
|
50
74
|
except Exception as e:
|
|
51
75
|
raise ConfigPluginError(f"Invalid configuration: {e}") from e
|
|
52
76
|
|
|
77
|
+
def get_locale_dir(self) -> Optional[str]:
|
|
78
|
+
"""Get this plugin's locale directory.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Path to the locale directory if it exists, None otherwise
|
|
82
|
+
"""
|
|
83
|
+
module = inspect.getmodule(self.__class__)
|
|
84
|
+
if module is None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
return self.get_locale_dir_from_module(module)
|
|
88
|
+
|
|
53
89
|
@abstractmethod
|
|
54
90
|
def process(self, app: PlatzkyEngine) -> PlatzkyEngine:
|
|
55
91
|
"""Process the plugin with the given app.
|
platzky/plugin/plugin_loader.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import importlib.util
|
|
2
2
|
import inspect
|
|
3
3
|
import logging
|
|
4
|
+
import os
|
|
4
5
|
from typing import Any, Optional, Type
|
|
5
6
|
|
|
6
7
|
import deprecation
|
|
@@ -49,24 +50,114 @@ def _is_class_plugin(plugin_module: Any) -> Optional[Type[PluginBase[Any]]]:
|
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
@deprecation.deprecated(
|
|
52
|
-
deprecated_in="
|
|
53
|
-
removed_in="0.
|
|
54
|
-
current_version=
|
|
55
|
-
details=
|
|
56
|
-
|
|
53
|
+
deprecated_in="1.2.0",
|
|
54
|
+
removed_in="2.0.0",
|
|
55
|
+
current_version="1.2.0",
|
|
56
|
+
details=(
|
|
57
|
+
"Legacy plugin style using the entrypoint process() function is deprecated. "
|
|
58
|
+
"Migrate to PluginBase to support plugin translations and other features. "
|
|
59
|
+
"See: https://platzky.readthedocs.io/en/latest/plugins.html"
|
|
60
|
+
),
|
|
57
61
|
)
|
|
58
|
-
def _process_legacy_plugin(
|
|
59
|
-
|
|
62
|
+
def _process_legacy_plugin(
|
|
63
|
+
plugin_module: Any, app: Engine, plugin_config: dict[str, Any], plugin_name: str
|
|
64
|
+
) -> Engine:
|
|
65
|
+
"""Process a legacy plugin using the entrypoint approach.
|
|
66
|
+
|
|
67
|
+
DEPRECATED: This function will be removed in version 2.0.0.
|
|
68
|
+
Please migrate your plugin to extend PluginBase.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
plugin_module: The plugin module
|
|
72
|
+
app: The Platzky Engine instance
|
|
73
|
+
plugin_config: Plugin configuration dictionary
|
|
74
|
+
plugin_name: Name of the plugin
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Platzky Engine with processed plugin
|
|
78
|
+
"""
|
|
60
79
|
app = plugin_module.process(app, plugin_config)
|
|
61
|
-
logger.
|
|
80
|
+
logger.warning(
|
|
81
|
+
"Plugin '%s' uses deprecated legacy interface. "
|
|
82
|
+
"This will be removed in version 2.0.0. "
|
|
83
|
+
"Migrate to PluginBase: https://platzky.readthedocs.io/",
|
|
84
|
+
plugin_name,
|
|
85
|
+
)
|
|
62
86
|
return app
|
|
63
87
|
|
|
64
88
|
|
|
89
|
+
def _is_safe_locale_dir(locale_dir: str, plugin_instance: PluginBase[Any]) -> bool:
|
|
90
|
+
"""Validate that a locale directory is safe to use.
|
|
91
|
+
|
|
92
|
+
Prevents malicious plugins from exposing arbitrary filesystem paths
|
|
93
|
+
by ensuring the locale directory is within the plugin's module directory.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
locale_dir: Path to the locale directory
|
|
97
|
+
plugin_instance: The plugin instance
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if the locale directory is safe to use, False otherwise
|
|
101
|
+
"""
|
|
102
|
+
if not os.path.isdir(locale_dir):
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
module = inspect.getmodule(plugin_instance.__class__)
|
|
106
|
+
if module is None or not hasattr(module, "__file__") or module.__file__ is None:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
normalized_path = os.path.normpath(locale_dir)
|
|
110
|
+
if ".." in normalized_path.split(os.sep):
|
|
111
|
+
logger.warning("Rejected locale path with .. components: %s", locale_dir)
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
# Get canonical paths (resolve symlinks)
|
|
115
|
+
locale_path = os.path.realpath(locale_dir)
|
|
116
|
+
module_path = os.path.realpath(os.path.dirname(module.__file__))
|
|
117
|
+
|
|
118
|
+
if not locale_path.startswith(module_path + os.sep):
|
|
119
|
+
if locale_path != module_path:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _register_plugin_locale(
|
|
126
|
+
app: Engine, plugin_instance: PluginBase[Any], plugin_name: str
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Register plugin's locale directory with Babel if it exists.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
app: The Platzky Engine instance
|
|
132
|
+
plugin_instance: The plugin instance
|
|
133
|
+
plugin_name: Name of the plugin for logging
|
|
134
|
+
"""
|
|
135
|
+
locale_dir = plugin_instance.get_locale_dir()
|
|
136
|
+
if locale_dir is None:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Validate that the locale directory is safe to use
|
|
140
|
+
if not _is_safe_locale_dir(locale_dir, plugin_instance):
|
|
141
|
+
logger.warning(
|
|
142
|
+
"Skipping locale directory for plugin %s: path validation failed: %s",
|
|
143
|
+
plugin_name,
|
|
144
|
+
locale_dir,
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
babel_config = app.extensions.get("babel")
|
|
149
|
+
if babel_config and locale_dir not in babel_config.translation_directories:
|
|
150
|
+
babel_config.translation_directories.append(locale_dir)
|
|
151
|
+
logger.info("Registered locale directory for plugin %s: %s", plugin_name, locale_dir)
|
|
152
|
+
|
|
153
|
+
|
|
65
154
|
def plugify(app: Engine) -> Engine:
|
|
66
155
|
"""Load plugins and run their entrypoints.
|
|
67
156
|
|
|
68
157
|
Supports both class-based plugins (PluginBase) and legacy entrypoint plugins.
|
|
69
158
|
|
|
159
|
+
Legacy plugin support is deprecated and will be removed in version 2.0.0.
|
|
160
|
+
|
|
70
161
|
Args:
|
|
71
162
|
app: Platzky Engine instance
|
|
72
163
|
|
|
@@ -91,8 +182,9 @@ def plugify(app: Engine) -> Engine:
|
|
|
91
182
|
if plugin_class:
|
|
92
183
|
# Handle new class-based plugins
|
|
93
184
|
plugin_instance = plugin_class(plugin_config)
|
|
185
|
+
_register_plugin_locale(app, plugin_instance, plugin_name)
|
|
94
186
|
app = plugin_instance.process(app)
|
|
95
|
-
logger.info(
|
|
187
|
+
logger.info("Processed class-based plugin: %s", plugin_name)
|
|
96
188
|
elif hasattr(plugin_module, "process"):
|
|
97
189
|
# Handle legacy entrypoint plugins with deprecation warning
|
|
98
190
|
app = _process_legacy_plugin(plugin_module, app, plugin_config, plugin_name)
|
|
@@ -102,8 +194,11 @@ def plugify(app: Engine) -> Engine:
|
|
|
102
194
|
f"or provide a process() function"
|
|
103
195
|
)
|
|
104
196
|
|
|
197
|
+
except PluginError:
|
|
198
|
+
# Re-raise PluginError directly to avoid redundant wrapping
|
|
199
|
+
raise
|
|
105
200
|
except Exception as e:
|
|
106
|
-
logger.
|
|
201
|
+
logger.exception("Error processing plugin %s", plugin_name)
|
|
107
202
|
raise PluginError(f"Error processing plugin {plugin_name}: {e}") from e
|
|
108
203
|
|
|
109
204
|
return app
|
platzky/seo/seo.py
CHANGED
|
@@ -27,7 +27,7 @@ def create_seo_blueprint(db, config: dict[str, t.Any], locale_func: t.Callable[[
|
|
|
27
27
|
lang
|
|
28
28
|
): # TODO add get_list_of_posts for faster getting just list of it
|
|
29
29
|
slug = post.slug
|
|
30
|
-
datet = post.date.
|
|
30
|
+
datet = post.date.date().isoformat()
|
|
31
31
|
url = {"loc": f"{host_base}{blog_prefix}/{slug}", "lastmod": datet}
|
|
32
32
|
dynamic_urls.append(url)
|
|
33
33
|
return dynamic_urls
|
platzky/telemetry.py
CHANGED
|
@@ -22,6 +22,7 @@ def setup_telemetry(app: "Engine", telemetry_config: TelemetryConfig) -> Optiona
|
|
|
22
22
|
|
|
23
23
|
Configures and initializes OpenTelemetry tracing with OTLP and/or console exporters.
|
|
24
24
|
Automatically instruments Flask to capture HTTP requests and trace information.
|
|
25
|
+
Optionally instruments logging to add trace context to log records.
|
|
25
26
|
|
|
26
27
|
Args:
|
|
27
28
|
app: Engine instance (Flask-based application)
|
|
@@ -51,6 +52,7 @@ def setup_telemetry(app: "Engine", telemetry_config: TelemetryConfig) -> Optiona
|
|
|
51
52
|
from opentelemetry import trace
|
|
52
53
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
53
54
|
from opentelemetry.instrumentation.flask import FlaskInstrumentor
|
|
55
|
+
from opentelemetry.instrumentation.logging import LoggingInstrumentor
|
|
54
56
|
from opentelemetry.sdk.resources import Resource
|
|
55
57
|
from opentelemetry.sdk.trace import TracerProvider
|
|
56
58
|
from opentelemetry.sdk.trace.export import (
|
|
@@ -107,6 +109,13 @@ def setup_telemetry(app: "Engine", telemetry_config: TelemetryConfig) -> Optiona
|
|
|
107
109
|
|
|
108
110
|
trace.set_tracer_provider(provider)
|
|
109
111
|
FlaskInstrumentor().instrument_app(app)
|
|
112
|
+
|
|
113
|
+
# Instrument logging to add trace context to log records
|
|
114
|
+
# Note: set_logging_format=False to avoid modifying existing log formats
|
|
115
|
+
# Users can access trace context in their custom formatters via log record attributes
|
|
116
|
+
if telemetry_config.instrument_logging:
|
|
117
|
+
LoggingInstrumentor().instrument(set_logging_format=False)
|
|
118
|
+
|
|
110
119
|
app.telemetry_instrumented = True
|
|
111
120
|
|
|
112
121
|
# Optionally flush spans after each request (may impact latency)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: platzky
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Not only blog engine
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -28,6 +28,7 @@ Requires-Dist: myst-parser (>=4.0.0,<5.0.0) ; extra == "docs"
|
|
|
28
28
|
Requires-Dist: opentelemetry-api (>=1.27.0,<2.0.0) ; extra == "telemetry"
|
|
29
29
|
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc (>=1.27.0,<2.0.0) ; extra == "telemetry"
|
|
30
30
|
Requires-Dist: opentelemetry-instrumentation-flask (>=0.48b0,<0.49) ; extra == "telemetry"
|
|
31
|
+
Requires-Dist: opentelemetry-instrumentation-logging (>=0.48b0,<0.49) ; extra == "telemetry"
|
|
31
32
|
Requires-Dist: opentelemetry-sdk (>=1.27.0,<2.0.0) ; extra == "telemetry"
|
|
32
33
|
Requires-Dist: pydantic (>=2.7.1,<3.0.0)
|
|
33
34
|
Requires-Dist: pygithub (>=2.6.1,<3.0.0)
|
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
platzky/__init__.py,sha256=IhL91rSWxIIJQNfVsqJ1d4yY5D2WyWcefo4Xv2aX_lo,180
|
|
2
2
|
platzky/admin/admin.py,sha256=PlwAXaR_YaM07knB88D33vNe6vCpJXOfuxpCaGnHQlY,1028
|
|
3
|
-
platzky/admin/fake_login.py,sha256=
|
|
3
|
+
platzky/admin/fake_login.py,sha256=YkWS-WD5hb-ClVMPO4vJA4Lt2teT9_xkTXQXzubwMdA,3507
|
|
4
4
|
platzky/admin/templates/admin.html,sha256=zgjROhSezayZqnNFezvVa0MEfgmXLvOM8HRRaZemkQw,688
|
|
5
5
|
platzky/admin/templates/login.html,sha256=oBNuv130iMTwXrtRnDUDcGIGvu0O2VsIbjQxw-Tjd7Y,380
|
|
6
6
|
platzky/admin/templates/module.html,sha256=WuQZxKQDD4INl-QF2uiKHf9Fmf2h7cEW9RLe1nWKC8k,175
|
|
7
7
|
platzky/blog/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
platzky/blog/blog.py,sha256=
|
|
8
|
+
platzky/blog/blog.py,sha256=YMbLLxza_bhL5wIoqklzhko4Ov9vXLibPHbv8XK1ueA,3600
|
|
9
9
|
platzky/blog/comment_form.py,sha256=4lkNJ_S_2DZmJBbz-NPDqahvy2Zz5AGNH2spFeGIop4,513
|
|
10
|
-
platzky/config.py,sha256=
|
|
10
|
+
platzky/config.py,sha256=NFnW-cjieWgER-uRko1Hbuh6cyGKCFQcq6ihDNSyjQk,7529
|
|
11
11
|
platzky/db/README.md,sha256=IO-LoDsd4dLBZenaz423EZjvEOQu_8m2OC0G7du170w,1753
|
|
12
12
|
platzky/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
-
platzky/db/db.py,sha256
|
|
13
|
+
platzky/db/db.py,sha256=-rwNDBPV_2fswIyQeecahjLSLkt5n0BYGnTUxGzGTio,3087
|
|
14
14
|
platzky/db/db_loader.py,sha256=CuEiXxhIa4bFMm0vi7ugzm7j3WycilGRKCU6smgIImE,905
|
|
15
15
|
platzky/db/github_json_db.py,sha256=G1GBIomeKOCeG05pA4qccaFntiGzkgyEMQJz_FQlvNY,2185
|
|
16
16
|
platzky/db/google_json_db.py,sha256=rS__UEK7ed71htTg066_vzpg0etTlpke6YkcrAQ3Fgk,1325
|
|
17
17
|
platzky/db/graph_ql_db.py,sha256=af6yy1R27YO8N9zJWU7VgU7optRgpdk_1ZUtab_1eT4,8967
|
|
18
|
-
platzky/db/json_db.py,sha256=
|
|
18
|
+
platzky/db/json_db.py,sha256=URsUykJc1-f5J3Uk64KeKki9Fdok_Be-XqmuhmAfJXE,4488
|
|
19
19
|
platzky/db/json_file_db.py,sha256=tPo92n5zG7vGpunn5vl66zISHBziQdxBttitvc5hPug,1030
|
|
20
20
|
platzky/db/mongodb_db.py,sha256=28KO8XmTEiqE7FcNBzw_pfxOy6Vo-T7qsHdUlh59QX0,5174
|
|
21
|
-
platzky/engine.py,sha256=
|
|
21
|
+
platzky/engine.py,sha256=hSkatp5EC8yKjY_mXkvbVrBnlabvlmcygle_OMzBc_o,5469
|
|
22
22
|
platzky/locale/en/LC_MESSAGES/messages.po,sha256=WaZGlFAegKRq7CSz69dWKic-mKvQFhVvssvExxNmGaU,1400
|
|
23
23
|
platzky/locale/pl/LC_MESSAGES/messages.po,sha256=sUPxMKDeEOoZ5UIg94rGxZD06YVWiAMWIby2XE51Hrc,1624
|
|
24
|
-
platzky/models.py,sha256=
|
|
25
|
-
platzky/platzky.py,sha256=
|
|
26
|
-
platzky/plugin/plugin.py,sha256=
|
|
27
|
-
platzky/plugin/plugin_loader.py,sha256=
|
|
28
|
-
platzky/seo/seo.py,sha256=
|
|
24
|
+
platzky/models.py,sha256=Z372NhIhZcJ92DLPlOq44gTu8XqVcw05SOeJ1BaU7zE,6767
|
|
25
|
+
platzky/platzky.py,sha256=1LKYq8pLm1QBlOcEPhugxWi8W0vuWqjjINIFK8b2Kow,9319
|
|
26
|
+
platzky/plugin/plugin.py,sha256=KZb6VEph__lx9xrv5Ay4h4XkFFYbodV5OimaG6B9IDc,2812
|
|
27
|
+
platzky/plugin/plugin_loader.py,sha256=1bhFQbOu_OKJr4ayth9-x4HMHi8eP96EO1ZQwQ57x0o,6657
|
|
28
|
+
platzky/seo/seo.py,sha256=pw7yrhZFQwWR6Ec7_j45JwQAkwAYBGBiQ4NKIB97bHY,2636
|
|
29
29
|
platzky/static/blog.css,sha256=TrppzgQbj4UtuTufDCdblyNTVAqgIbhD66Cziyv_xnY,7893
|
|
30
30
|
platzky/static/styles.css,sha256=U5ddGIK-VcGRJZ3BdOpMp0pR__k6rNEMsuQXkP4tFQ0,686
|
|
31
|
-
platzky/telemetry.py,sha256=
|
|
31
|
+
platzky/telemetry.py,sha256=iXYvEt0Uw5Hx8lAxyr45dpQ_SiE2NxmJkoSx-JSRJyM,5011
|
|
32
32
|
platzky/templates/404.html,sha256=EheoLSWylOscLH8FmcMA4c6Jw14i5HkSvE_GXzGIrUo,78
|
|
33
33
|
platzky/templates/base.html,sha256=clvWlVOxNLqSQxBpPao3qnKKzkU2q48Apf1WbHJgYfE,4003
|
|
34
34
|
platzky/templates/blog.html,sha256=aPl-DzLX85bHv7tN8UjlABR086PUJ9IGlGbIBioFHGA,1281
|
|
@@ -41,7 +41,7 @@ platzky/templates/post.html,sha256=GSgjIZsOQKtNx3cEbquSjZ5L4whPnG6MzRyoq9k4B8Q,1
|
|
|
41
41
|
platzky/templates/robots.txt,sha256=2_j2tiYtYJnzZUrANiX9pvBxyw5Dp27fR_co18BPEJ0,116
|
|
42
42
|
platzky/templates/sitemap.xml,sha256=iIJZ91_B5ZuNLCHsRtsGKZlBAXojOTP8kffqKLacgvs,578
|
|
43
43
|
platzky/www_handler.py,sha256=pF6Rmvem1sdVqHD7z3RLrDuG-CwAqfGCti50_NPsB2w,725
|
|
44
|
-
platzky-1.
|
|
45
|
-
platzky-1.
|
|
46
|
-
platzky-1.
|
|
47
|
-
platzky-1.
|
|
44
|
+
platzky-1.1.0.dist-info/METADATA,sha256=gunNzvqEk_vWisK0fPkYaS4D625Pn89HV8QQeezaIc8,2556
|
|
45
|
+
platzky-1.1.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
46
|
+
platzky-1.1.0.dist-info/licenses/LICENSE,sha256=wCdfk-qEosi6BDwiBulMfKMi0hxp1UXV0DdjLrRm788,1077
|
|
47
|
+
platzky-1.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|