platzky 1.0.1__py3-none-any.whl → 1.2.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.
@@ -1,4 +1,7 @@
1
+ """Local file-based JSON database implementation."""
2
+
1
3
  import json
4
+ from typing import Any
2
5
 
3
6
  from pydantic import Field
4
7
 
@@ -6,25 +9,55 @@ from platzky.db.db import DBConfig
6
9
  from platzky.db.json_db import Json
7
10
 
8
11
 
9
- def db_config_type():
12
+ def db_config_type() -> type["JsonFileDbConfig"]:
13
+ """Return the configuration class for JSON file database.
14
+
15
+ Returns:
16
+ JsonFileDbConfig class
17
+ """
10
18
  return JsonFileDbConfig
11
19
 
12
20
 
13
21
  class JsonFileDbConfig(DBConfig):
22
+ """Configuration for JSON file database."""
23
+
14
24
  path: str = Field(alias="PATH")
15
25
 
16
26
 
17
- def get_db(config):
27
+ def get_db(config: dict[str, Any]) -> "JsonFile":
28
+ """Get a JSON file database instance from raw configuration.
29
+
30
+ Args:
31
+ config: Raw configuration dictionary
32
+
33
+ Returns:
34
+ Configured JSON file database instance
35
+ """
18
36
  json_file_db_config = JsonFileDbConfig.model_validate(config)
19
37
  return JsonFile(json_file_db_config.path)
20
38
 
21
39
 
22
- def db_from_config(config: JsonFileDbConfig):
40
+ def db_from_config(config: JsonFileDbConfig) -> "JsonFile":
41
+ """Create a JSON file database instance from configuration.
42
+
43
+ Args:
44
+ config: JSON file database configuration
45
+
46
+ Returns:
47
+ Configured JSON file database instance
48
+ """
23
49
  return JsonFile(config.path)
24
50
 
25
51
 
26
52
  class JsonFile(Json):
27
- def __init__(self, path: str):
53
+ """JSON database stored in a local file with read/write support."""
54
+
55
+ def __init__(self, path: str) -> None:
56
+ """Initialize JSON file database from a local file path.
57
+
58
+ Args:
59
+ path: Absolute or relative path to the JSON file
60
+ """
28
61
  self.data_file_path = path
29
62
  with open(self.data_file_path) as json_file:
30
63
  data = json.load(json_file)
@@ -32,10 +65,17 @@ class JsonFile(Json):
32
65
  self.module_name = "json_file_db"
33
66
  self.db_name = "JsonFileDb"
34
67
 
35
- def __save_file(self):
68
+ def __save_file(self) -> None:
36
69
  with open(self.data_file_path, "w") as json_file:
37
70
  json.dump(self.data, json_file)
38
71
 
39
- def add_comment(self, author_name, comment, post_slug):
72
+ def add_comment(self, author_name: str, comment: str, post_slug: str) -> None:
73
+ """Add a comment to a blog post and persist to file.
74
+
75
+ Args:
76
+ author_name: Name of the comment author
77
+ comment: Comment text content
78
+ post_slug: URL-friendly identifier of the post
79
+ """
40
80
  super().add_comment(author_name, comment, post_slug)
41
81
  self.__save_file()
platzky/db/mongodb_db.py CHANGED
@@ -1,3 +1,5 @@
1
+ """MongoDB database implementation."""
2
+
1
3
  import datetime
2
4
  from typing import Any
3
5
 
@@ -10,26 +12,57 @@ from platzky.db.db import DB, DBConfig
10
12
  from platzky.models import MenuItem, Page, Post
11
13
 
12
14
 
13
- def db_config_type():
15
+ def db_config_type() -> type["MongoDbConfig"]:
16
+ """Return the configuration class for MongoDB database.
17
+
18
+ Returns:
19
+ MongoDbConfig class
20
+ """
14
21
  return MongoDbConfig
15
22
 
16
23
 
17
24
  class MongoDbConfig(DBConfig):
25
+ """Configuration for MongoDB database connection."""
26
+
18
27
  connection_string: str = Field(alias="CONNECTION_STRING")
19
28
  database_name: str = Field(alias="DATABASE_NAME")
20
29
 
21
30
 
22
- def get_db(config):
31
+ def get_db(config: dict[str, Any]) -> "MongoDB":
32
+ """Get a MongoDB database instance from raw configuration.
33
+
34
+ Args:
35
+ config: Raw configuration dictionary
36
+
37
+ Returns:
38
+ Configured MongoDB database instance
39
+ """
23
40
  mongodb_config = MongoDbConfig.model_validate(config)
24
41
  return MongoDB(mongodb_config.connection_string, mongodb_config.database_name)
25
42
 
26
43
 
27
- def db_from_config(config: MongoDbConfig):
44
+ def db_from_config(config: MongoDbConfig) -> "MongoDB":
45
+ """Create a MongoDB database instance from configuration.
46
+
47
+ Args:
48
+ config: MongoDB database configuration
49
+
50
+ Returns:
51
+ Configured MongoDB database instance
52
+ """
28
53
  return MongoDB(config.connection_string, config.database_name)
29
54
 
30
55
 
31
56
  class MongoDB(DB):
57
+ """MongoDB database implementation with connection pooling."""
58
+
32
59
  def __init__(self, connection_string: str, database_name: str):
60
+ """Initialize MongoDB database connection.
61
+
62
+ Args:
63
+ connection_string: MongoDB connection URI
64
+ database_name: Name of the database to use
65
+ """
33
66
  super().__init__()
34
67
  self.connection_string = connection_string
35
68
  self.database_name = database_name
@@ -46,38 +79,103 @@ class MongoDB(DB):
46
79
  self.plugins: Collection[Any] = self.db.plugins
47
80
 
48
81
  def get_app_description(self, lang: str) -> str:
82
+ """Retrieve the application description for a specific language.
83
+
84
+ Args:
85
+ lang: Language code (e.g., 'en', 'pl')
86
+
87
+ Returns:
88
+ Application description text or empty string if not found
89
+ """
49
90
  site_content = self.site_content.find_one({"_id": "config"})
50
91
  if site_content and "app_description" in site_content:
51
92
  return site_content["app_description"].get(lang, "")
52
93
  return ""
53
94
 
54
95
  def get_all_posts(self, lang: str) -> list[Post]:
96
+ """Retrieve all posts for a specific language.
97
+
98
+ Args:
99
+ lang: Language code (e.g., 'en', 'pl')
100
+
101
+ Returns:
102
+ List of Post objects
103
+ """
55
104
  posts_cursor = self.posts.find({"language": lang})
56
105
  return [Post.model_validate(post) for post in posts_cursor]
57
106
 
58
107
  def get_menu_items_in_lang(self, lang: str) -> list[MenuItem]:
108
+ """Retrieve menu items for a specific language.
109
+
110
+ Args:
111
+ lang: Language code (e.g., 'en', 'pl')
112
+
113
+ Returns:
114
+ List of MenuItem objects
115
+ """
59
116
  menu_items_doc = self.menu_items.find_one({"_id": lang})
60
117
  if menu_items_doc and "items" in menu_items_doc:
61
118
  return [MenuItem.model_validate(item) for item in menu_items_doc["items"]]
62
119
  return []
63
120
 
64
121
  def get_post(self, slug: str) -> Post:
122
+ """Retrieve a single post by its slug.
123
+
124
+ Args:
125
+ slug: URL-friendly identifier for the post
126
+
127
+ Returns:
128
+ Post object
129
+
130
+ Raises:
131
+ ValueError: If post not found
132
+ """
65
133
  post_doc = self.posts.find_one({"slug": slug})
66
134
  if post_doc is None:
67
135
  raise ValueError(f"Post with slug {slug} not found")
68
136
  return Post.model_validate(post_doc)
69
137
 
70
138
  def get_page(self, slug: str) -> Page:
139
+ """Retrieve a page by its slug.
140
+
141
+ Args:
142
+ slug: URL-friendly identifier for the page
143
+
144
+ Returns:
145
+ Page object
146
+
147
+ Raises:
148
+ ValueError: If page not found
149
+ """
71
150
  page_doc = self.pages.find_one({"slug": slug})
72
151
  if page_doc is None:
73
152
  raise ValueError(f"Page with slug {slug} not found")
74
153
  return Page.model_validate(page_doc)
75
154
 
76
- def get_posts_by_tag(self, tag: str, lang: str) -> Any:
155
+ def get_posts_by_tag(self, tag: str, lang: str) -> list[Post]:
156
+ """Retrieve posts filtered by tag and language.
157
+
158
+ Args:
159
+ tag: Tag name to filter by
160
+ lang: Language code (e.g., 'en', 'pl')
161
+
162
+ Returns:
163
+ List of Post objects matching the tag and language
164
+ """
77
165
  posts_cursor = self.posts.find({"tags": tag, "language": lang})
78
- return posts_cursor
166
+ return [Post.model_validate(post) for post in posts_cursor]
79
167
 
80
168
  def add_comment(self, author_name: str, comment: str, post_slug: str) -> None:
169
+ """Add a new comment to a post.
170
+
171
+ Args:
172
+ author_name: Name of the comment author
173
+ comment: Comment text content
174
+ post_slug: URL-friendly identifier of the post
175
+
176
+ Raises:
177
+ ValueError: If post not found
178
+ """
81
179
  now_utc = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
82
180
  comment_doc = {
83
181
  "author": str(author_name),
@@ -90,36 +188,66 @@ class MongoDB(DB):
90
188
  raise ValueError(f"Post with slug {post_slug} not found")
91
189
 
92
190
  def get_logo_url(self) -> str:
191
+ """Retrieve the URL of the application logo.
192
+
193
+ Returns:
194
+ Logo image URL or empty string if not found
195
+ """
93
196
  site_content = self.site_content.find_one({"_id": "config"})
94
197
  if site_content:
95
198
  return site_content.get("logo_url", "")
96
199
  return ""
97
200
 
98
201
  def get_favicon_url(self) -> str:
202
+ """Retrieve the URL of the application favicon.
203
+
204
+ Returns:
205
+ Favicon URL or empty string if not found
206
+ """
99
207
  site_content = self.site_content.find_one({"_id": "config"})
100
208
  if site_content:
101
209
  return site_content.get("favicon_url", "")
102
210
  return ""
103
211
 
104
212
  def get_primary_color(self) -> str:
213
+ """Retrieve the primary color for the application theme.
214
+
215
+ Returns:
216
+ Primary color value, defaults to 'white'
217
+ """
105
218
  site_content = self.site_content.find_one({"_id": "config"})
106
219
  if site_content:
107
220
  return site_content.get("primary_color", "white")
108
221
  return "white"
109
222
 
110
223
  def get_secondary_color(self) -> str:
224
+ """Retrieve the secondary color for the application theme.
225
+
226
+ Returns:
227
+ Secondary color value, defaults to 'navy'
228
+ """
111
229
  site_content = self.site_content.find_one({"_id": "config"})
112
230
  if site_content:
113
231
  return site_content.get("secondary_color", "navy")
114
232
  return "navy"
115
233
 
116
- def get_plugins_data(self) -> list[Any]:
234
+ def get_plugins_data(self) -> list[dict[str, Any]]:
235
+ """Retrieve configuration data for all plugins.
236
+
237
+ Returns:
238
+ List of plugin configuration dictionaries
239
+ """
117
240
  plugins_doc = self.plugins.find_one({"_id": "config"})
118
241
  if plugins_doc and "data" in plugins_doc:
119
242
  return plugins_doc["data"]
120
243
  return []
121
244
 
122
245
  def get_font(self) -> str:
246
+ """Get the font configuration for the application.
247
+
248
+ Returns:
249
+ Font name or empty string if not configured
250
+ """
123
251
  site_content = self.site_content.find_one({"_id": "config"})
124
252
  if site_content:
125
253
  return site_content.get("font", "")
platzky/engine.py CHANGED
@@ -1,16 +1,18 @@
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
7
8
 
8
9
  from platzky.config import Config
10
+ from platzky.db.db import DB
9
11
  from platzky.models import CmsModule
10
12
 
11
13
 
12
14
  class Engine(Flask):
13
- def __init__(self, config: Config, db, import_name):
15
+ def __init__(self, config: Config, db: DB, import_name: str) -> None:
14
16
  super().__init__(import_name)
15
17
  self.config.from_mapping(config.model_dump(by_alias=True))
16
18
  self.db = db
@@ -18,7 +20,7 @@ class Engine(Flask):
18
20
  self.login_methods = []
19
21
  self.dynamic_body = ""
20
22
  self.dynamic_head = ""
21
- self.health_checks: List[Tuple[str, Callable[[], None]]] = []
23
+ self.health_checks: list[tuple[str, Callable[[], None]]] = []
22
24
  self.telemetry_instrumented: bool = False
23
25
  directory = os.path.dirname(os.path.realpath(__file__))
24
26
  locale_dir = os.path.join(directory, "locale")
@@ -31,7 +33,7 @@ class Engine(Flask):
31
33
  )
32
34
  self._register_default_health_endpoints()
33
35
 
34
- self.cms_modules: List[CmsModule] = []
36
+ self.cms_modules: list[CmsModule] = []
35
37
  # TODO add plugins as CMS Module - all plugins should be visible from
36
38
  # admin page at least as configuration
37
39
 
@@ -39,7 +41,7 @@ class Engine(Flask):
39
41
  for notifier in self.notifiers:
40
42
  notifier(message)
41
43
 
42
- def add_notifier(self, notifier):
44
+ def add_notifier(self, notifier: Callable[[str], None]) -> None:
43
45
  self.notifiers.append(notifier)
44
46
 
45
47
  def add_cms_module(self, module: CmsModule):
@@ -47,7 +49,7 @@ class Engine(Flask):
47
49
  self.cms_modules.append(module)
48
50
 
49
51
  # TODO login_method should be interface
50
- def add_login_method(self, login_method):
52
+ def add_login_method(self, login_method: Callable[[], str]) -> None:
51
53
  self.login_methods.append(login_method)
52
54
 
53
55
  def add_dynamic_body(self, body: str):
@@ -94,7 +96,7 @@ class Engine(Flask):
94
96
  @health_bp.route("/health/readiness")
95
97
  def readiness():
96
98
  """Readiness check - can the app serve traffic?"""
97
- health_status: Dict[str, Any] = {"status": "ready", "checks": {}}
99
+ health_status: dict[str, Any] = {"status": "ready", "checks": {}}
98
100
  status_code = 200
99
101
 
100
102
  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
+ )