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.
@@ -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
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(e):
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
- return page_not_found("no posts")
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
- @blog.route("/<post_slug>", methods=["GET"])
53
- def get_post(post_slug):
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
- post = db.get_post(post_slug)
56
- return render_template(
57
- "post.html",
58
- post=post,
59
- post_slug=post_slug,
60
- form=comment_form.CommentForm(),
61
- comment_sent=request.args.get("comment_sent"),
62
- )
63
- except ValueError:
64
- return page_not_found(f"no post with slug {post_slug}")
65
- except Exception as e:
66
- return page_not_found(str(e))
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
- ): # TODO refactor to share code with get_post since they are very similar
72
- try:
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
@@ -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
 
platzky/db/json_db.py CHANGED
@@ -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(
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, 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)
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: str # TODO change its type to datetime
138
+ date: DateTimeField
37
139
 
38
140
  @property
39
141
  def time_delta(self) -> str:
40
- now = datetime.datetime.now()
41
- date = datetime.datetime.strptime(self.date.split(".")[0], "%Y-%m-%dT%H:%M:%S")
42
- return humanize.naturaltime(now - date)
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: str
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
- def __lt__(self, other):
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
- raise NotImplementedError("Posts can only be compared with other posts")
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
- def __init__(self, r: int = 0, g: int = 0, b: int = 0, a: int = 255):
68
- if not (0 <= r <= 255):
69
- raise ValueError("r must be between 0 and 255")
70
- if not (0 <= g <= 255):
71
- raise ValueError("g must be between 0 and 255")
72
- if not (0 <= b <= 255):
73
- raise ValueError("b must be between 0 and 255")
74
- if not (0 <= a <= 255):
75
- raise ValueError("a must be between 0 and 255")
76
- super().__init__(r=r, g=g, b=b, a=a)
77
-
78
- r: int
79
- g: int
80
- b: int
81
- a: int
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 create_engine(config: Config, db) -> Engine:
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
- else:
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
- if new_domain := get_langs_domain(lang):
46
- return redirect("http://" + new_domain, code=301)
47
- else:
48
- session["language"] = lang
49
- return redirect(request.referrer)
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
- return urllib.parse.quote(x, safe="")
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
- flag = lang.flag if (lang := config.languages.get(locale)) is not None else ""
58
- country = lang.country if (lang := config.languages.get(locale)) is not None else ""
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(e):
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, Dict, Generic, Type, TypeVar
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) -> Type[PluginBaseConfig]:
67
+ def get_config_model(cls) -> type[PluginBaseConfig]:
44
68
  return PluginBaseConfig
45
69
 
46
- def __init__(self, config: Dict[str, Any]):
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.
@@ -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="0.3.1",
53
- removed_in="0.4.0",
54
- current_version=None, # You should replace this with the current version
55
- details="Legacy plugin style using the entrypoint process() function is deprecated. "
56
- "Please migrate to the PluginBase interface.",
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(plugin_module, app, plugin_config, plugin_name):
59
- """Process a legacy plugin using the entrypoint approach."""
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.info(f"Processed legacy plugin: {plugin_name}")
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(f"Processed class-based plugin: {plugin_name}")
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.error(f"Error processing plugin {plugin_name}: {e}")
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.split("T")[0]
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.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)
@@ -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=tGsdofPyI3P65pol_3Nbj5075xjA_CU090brnBVNjxg,3480
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=L9IYWxnLo1v1h_wLF-0HyG1Y4RSGg7maEMnYxhTgG5Y,2971
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=N5cQjV8Jh_fETw9jXE-UOcC-bSlC3l_04yMMtRwXKXw,7365
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=0h5rGCBO_N1wBqJRl5EoiW_bFDpNIvmNwuA0hJi89jw,3060
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=NUBPy4jt-y37TYq4SCGaSgief3MbBWL_Efw8Bxp8Jo0,4046
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=0bxKzfK83ic-VNlKcDut_84_u5EmY2baU5JcJsleUoM,5461
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=DZZgKW2Q3fY2GMdikFUmAgpsRqT5VKAOwP6RmEsmO2M,1871
25
- platzky/platzky.py,sha256=8mTqdYqRKONv2oGvQF5Y0fuOomH7ZFYvXtxc7ZohBLE,4585
26
- platzky/plugin/plugin.py,sha256=tV8aobIzMDJe1frKUAi4kLbrTAIS0FWE3oYpktSo6Ug,1633
27
- platzky/plugin/plugin_loader.py,sha256=MeQ8LNbrOomwXgc1ISHuyhjZd2mzYKen70eDShWs-Co,3497
28
- platzky/seo/seo.py,sha256=N_MmAA4KJZmmrDUh0hYNtD8ycOwpNKow4gVSAv8V3N4,2631
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=NQuueK-uwZDdnpWaOTFSMvzRWWg6Yu4jPOMPYdMidNY,4524
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.0.0.dist-info/METADATA,sha256=NSQ3OIyrJ4NQsdu3QRIo-c4jQQ9mM0HoqwmQK2EPvKc,2463
45
- platzky-1.0.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
46
- platzky-1.0.0.dist-info/licenses/LICENSE,sha256=wCdfk-qEosi6BDwiBulMfKMi0hxp1UXV0DdjLrRm788,1077
47
- platzky-1.0.0.dist-info/RECORD,,
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,,