platzky 1.0.0__tar.gz → 1.1.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 (51) hide show
  1. {platzky-1.0.0 → platzky-1.1.0}/PKG-INFO +2 -1
  2. {platzky-1.0.0 → platzky-1.1.0}/platzky/admin/fake_login.py +2 -1
  3. platzky-1.1.0/platzky/blog/blog.py +112 -0
  4. {platzky-1.0.0 → platzky-1.1.0}/platzky/config.py +2 -0
  5. {platzky-1.0.0 → platzky-1.1.0}/platzky/db/db.py +2 -1
  6. {platzky-1.0.0 → platzky-1.1.0}/platzky/db/json_db.py +14 -6
  7. {platzky-1.0.0 → platzky-1.1.0}/platzky/engine.py +5 -4
  8. platzky-1.1.0/platzky/models.py +217 -0
  9. platzky-1.1.0/platzky/platzky.py +283 -0
  10. {platzky-1.0.0 → platzky-1.1.0}/platzky/plugin/plugin.py +39 -3
  11. platzky-1.1.0/platzky/plugin/plugin_loader.py +204 -0
  12. {platzky-1.0.0 → platzky-1.1.0}/platzky/seo/seo.py +1 -1
  13. {platzky-1.0.0 → platzky-1.1.0}/platzky/telemetry.py +9 -0
  14. {platzky-1.0.0 → platzky-1.1.0}/pyproject.toml +24 -3
  15. platzky-1.0.0/platzky/blog/blog.py +0 -90
  16. platzky-1.0.0/platzky/models.py +0 -81
  17. platzky-1.0.0/platzky/platzky.py +0 -137
  18. platzky-1.0.0/platzky/plugin/plugin_loader.py +0 -109
  19. {platzky-1.0.0 → platzky-1.1.0}/LICENSE +0 -0
  20. {platzky-1.0.0 → platzky-1.1.0}/README.md +0 -0
  21. {platzky-1.0.0 → platzky-1.1.0}/platzky/__init__.py +0 -0
  22. {platzky-1.0.0 → platzky-1.1.0}/platzky/admin/admin.py +0 -0
  23. {platzky-1.0.0 → platzky-1.1.0}/platzky/admin/templates/admin.html +0 -0
  24. {platzky-1.0.0 → platzky-1.1.0}/platzky/admin/templates/login.html +0 -0
  25. {platzky-1.0.0 → platzky-1.1.0}/platzky/admin/templates/module.html +0 -0
  26. {platzky-1.0.0 → platzky-1.1.0}/platzky/blog/__init__.py +0 -0
  27. {platzky-1.0.0 → platzky-1.1.0}/platzky/blog/comment_form.py +0 -0
  28. {platzky-1.0.0 → platzky-1.1.0}/platzky/db/README.md +0 -0
  29. {platzky-1.0.0 → platzky-1.1.0}/platzky/db/__init__.py +0 -0
  30. {platzky-1.0.0 → platzky-1.1.0}/platzky/db/db_loader.py +0 -0
  31. {platzky-1.0.0 → platzky-1.1.0}/platzky/db/github_json_db.py +0 -0
  32. {platzky-1.0.0 → platzky-1.1.0}/platzky/db/google_json_db.py +0 -0
  33. {platzky-1.0.0 → platzky-1.1.0}/platzky/db/graph_ql_db.py +0 -0
  34. {platzky-1.0.0 → platzky-1.1.0}/platzky/db/json_file_db.py +0 -0
  35. {platzky-1.0.0 → platzky-1.1.0}/platzky/db/mongodb_db.py +0 -0
  36. {platzky-1.0.0 → platzky-1.1.0}/platzky/locale/en/LC_MESSAGES/messages.po +0 -0
  37. {platzky-1.0.0 → platzky-1.1.0}/platzky/locale/pl/LC_MESSAGES/messages.po +0 -0
  38. {platzky-1.0.0 → platzky-1.1.0}/platzky/static/blog.css +0 -0
  39. {platzky-1.0.0 → platzky-1.1.0}/platzky/static/styles.css +0 -0
  40. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/404.html +0 -0
  41. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/base.html +0 -0
  42. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/blog.html +0 -0
  43. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/body_meta.html +0 -0
  44. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/dynamic_css.html +0 -0
  45. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/feed.xml +0 -0
  46. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/head_meta.html +0 -0
  47. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/page.html +0 -0
  48. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/post.html +0 -0
  49. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/robots.txt +0 -0
  50. {platzky-1.0.0 → platzky-1.1.0}/platzky/templates/sitemap.xml +0 -0
  51. {platzky-1.0.0 → platzky-1.1.0}/platzky/www_handler.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: platzky
3
- Version: 1.0.0
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)
@@ -6,7 +6,8 @@ environments as it bypasses proper authentication and authorization controls.
6
6
  """
7
7
 
8
8
  import os
9
- from typing import Any, Callable
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
@@ -0,0 +1,112 @@
1
+ import logging
2
+ from collections.abc import Callable
3
+ from os.path import dirname
4
+ from typing import Any
5
+
6
+ from flask import Blueprint, abort, make_response, render_template, request
7
+ from markupsafe import Markup
8
+ from werkzeug.exceptions import HTTPException
9
+ from werkzeug.wrappers import Response
10
+
11
+ from . import comment_form
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def create_blog_blueprint(db, blog_prefix: str, locale_func):
17
+ url_prefix = blog_prefix
18
+ blog = Blueprint(
19
+ "blog",
20
+ __name__,
21
+ url_prefix=url_prefix,
22
+ template_folder=f"{dirname(__file__)}/../templates",
23
+ )
24
+
25
+ @blog.app_template_filter()
26
+ def markdown(text):
27
+ return Markup(text)
28
+
29
+ @blog.errorhandler(404)
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
+ """
39
+ return render_template("404.html", title="404"), 404
40
+
41
+ @blog.route("/", methods=["GET"])
42
+ def all_posts() -> str:
43
+ lang = locale_func()
44
+ posts = db.get_all_posts(lang)
45
+ if not posts:
46
+ abort(404)
47
+ posts_sorted = sorted(posts, reverse=True)
48
+ return render_template("blog.html", posts=posts_sorted)
49
+
50
+ @blog.route("/feed", methods=["GET"])
51
+ def get_feed() -> Response:
52
+ lang = locale_func()
53
+ response = make_response(render_template("feed.xml", posts=db.get_all_posts(lang)))
54
+ response.headers["Content-Type"] = "application/xml"
55
+ return response
56
+
57
+ @blog.route("/<post_slug>", methods=["POST"])
58
+ def post_comment(post_slug: str) -> str:
59
+ comment = request.form.to_dict()
60
+ db.add_comment(
61
+ post_slug=post_slug,
62
+ author_name=comment["author_name"],
63
+ comment=comment["comment"],
64
+ )
65
+ return get_post(post_slug=post_slug)
66
+
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
+ """
83
+ try:
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
+ )
99
+
100
+ @blog.route("/page/<path:page_slug>", methods=["GET"])
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)
105
+
106
+ @blog.route("/tag/<path:tag>", methods=["GET"])
107
+ def get_posts_from_tag(tag: str) -> str:
108
+ lang = locale_func()
109
+ posts = db.get_posts_by_tag(tag, lang)
110
+ return render_template("blog.html", posts=posts, subtitle=f" - tag: {tag}")
111
+
112
+ return blog
@@ -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
@@ -1,6 +1,7 @@
1
1
  from abc import ABC, abstractmethod
2
+ from collections.abc import Callable
2
3
  from functools import partial
3
- from typing import Any, Callable
4
+ from typing import Any
4
5
 
5
6
  from pydantic import BaseModel, Field
6
7
 
@@ -1,5 +1,5 @@
1
1
  import datetime
2
- from typing import Any, Dict
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: Dict[str, Any] = Field(alias="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: Dict[str, Any]):
33
+ def __init__(self, data: dict[str, Any]):
34
34
  super().__init__()
35
- self.data: Dict[str, Any] = 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
- page = Post.model_validate(next(list_of_pages))
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": datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
115
+ "date": now_utc,
108
116
  }
109
117
 
110
118
  post_index = next(
@@ -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, Callable, Dict, List, Tuple
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: List[Tuple[str, Callable[[], None]]] = []
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: List[CmsModule] = []
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: Dict[str, Any] = {"status": "ready", "checks": {}}
98
+ health_status: dict[str, Any] = {"status": "ready", "checks": {}}
98
99
  status_code = 200
99
100
 
100
101
  executor = ThreadPoolExecutor(max_workers=1)
@@ -0,0 +1,217 @@
1
+ import datetime
2
+ import warnings
3
+ from typing import Annotated
4
+
5
+ import humanize
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
+ ]
85
+
86
+
87
+ class CmsModule(BaseModel):
88
+ """Represents a CMS module with basic metadata."""
89
+
90
+ name: str
91
+ description: str
92
+ template: str
93
+ slug: str
94
+
95
+
96
+ # CmsModuleGroup is also a CmsModule, but it contains other CmsModules
97
+ class CmsModuleGroup(CmsModule):
98
+ """Represents a group of CMS modules, inheriting module properties."""
99
+
100
+ modules: list[CmsModule] = []
101
+
102
+
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
+
111
+ url: str = ""
112
+ alternateText: str = ""
113
+
114
+
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
+
123
+ name: str
124
+ url: str
125
+
126
+
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
+
136
+ author: str
137
+ comment: str
138
+ date: DateTimeField
139
+
140
+ @property
141
+ def time_delta(self) -> str:
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)
153
+
154
+
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
+
171
+ author: str
172
+ slug: str
173
+ title: str
174
+ contentInMarkdown: str
175
+ comments: list[Comment]
176
+ excerpt: str
177
+ tags: list[str]
178
+ language: str
179
+ coverImage: Image
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
189
+
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
+ """
194
+ if isinstance(other, Post):
195
+ return self.date < other.date
196
+ return NotImplemented
197
+
198
+
199
+ Page = Post # Page is an alias for Post (static pages use the same structure)
200
+
201
+
202
+ class Color(BaseModel):
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
+ )