platzky 1.3.1__tar.gz → 1.4.1__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 (57) hide show
  1. {platzky-1.3.1 → platzky-1.4.1}/PKG-INFO +1 -1
  2. platzky-1.4.1/platzky/__init__.py +9 -0
  3. {platzky-1.3.1 → platzky-1.4.1}/platzky/admin/admin.py +1 -2
  4. {platzky-1.3.1 → platzky-1.4.1}/platzky/blog/blog.py +2 -3
  5. {platzky-1.3.1 → platzky-1.4.1}/platzky/config.py +57 -29
  6. {platzky-1.3.1 → platzky-1.4.1}/platzky/db/github_json_db.py +1 -7
  7. {platzky-1.3.1 → platzky-1.4.1}/platzky/db/google_json_db.py +1 -2
  8. {platzky-1.3.1 → platzky-1.4.1}/platzky/db/graph_ql_db.py +4 -12
  9. {platzky-1.3.1 → platzky-1.4.1}/platzky/db/json_db.py +10 -16
  10. {platzky-1.3.1 → platzky-1.4.1}/platzky/db/json_file_db.py +1 -2
  11. {platzky-1.3.1 → platzky-1.4.1}/platzky/db/mongodb_db.py +18 -25
  12. platzky-1.4.1/platzky/debug/__init__.py +9 -0
  13. platzky-1.4.1/platzky/debug/blueprint.py +51 -0
  14. {platzky-1.3.1/platzky/admin → platzky-1.4.1/platzky/debug}/fake_login.py +15 -20
  15. {platzky-1.3.1 → platzky-1.4.1}/platzky/engine.py +19 -4
  16. platzky-1.4.1/platzky/feature_flags.py +141 -0
  17. platzky-1.4.1/platzky/feature_flags_wrapper.py +93 -0
  18. {platzky-1.3.1 → platzky-1.4.1}/platzky/platzky.py +10 -17
  19. {platzky-1.3.1 → platzky-1.4.1}/platzky/seo/seo.py +5 -6
  20. {platzky-1.3.1 → platzky-1.4.1}/pyproject.toml +2 -1
  21. platzky-1.3.1/platzky/__init__.py +0 -3
  22. {platzky-1.3.1 → platzky-1.4.1}/LICENSE +0 -0
  23. {platzky-1.3.1 → platzky-1.4.1}/README.md +0 -0
  24. {platzky-1.3.1 → platzky-1.4.1}/platzky/admin/templates/admin.html +0 -0
  25. {platzky-1.3.1 → platzky-1.4.1}/platzky/admin/templates/login.html +0 -0
  26. {platzky-1.3.1 → platzky-1.4.1}/platzky/admin/templates/module.html +0 -0
  27. {platzky-1.3.1 → platzky-1.4.1}/platzky/attachment/__init__.py +0 -0
  28. {platzky-1.3.1 → platzky-1.4.1}/platzky/attachment/constants.py +0 -0
  29. {platzky-1.3.1 → platzky-1.4.1}/platzky/attachment/core.py +0 -0
  30. {platzky-1.3.1 → platzky-1.4.1}/platzky/attachment/mime_validation.py +0 -0
  31. {platzky-1.3.1 → platzky-1.4.1}/platzky/blog/__init__.py +0 -0
  32. {platzky-1.3.1 → platzky-1.4.1}/platzky/blog/comment_form.py +0 -0
  33. {platzky-1.3.1 → platzky-1.4.1}/platzky/db/README.md +0 -0
  34. {platzky-1.3.1 → platzky-1.4.1}/platzky/db/__init__.py +0 -0
  35. {platzky-1.3.1 → platzky-1.4.1}/platzky/db/db.py +0 -0
  36. {platzky-1.3.1 → platzky-1.4.1}/platzky/db/db_loader.py +0 -0
  37. {platzky-1.3.1 → platzky-1.4.1}/platzky/locale/en/LC_MESSAGES/messages.po +0 -0
  38. {platzky-1.3.1 → platzky-1.4.1}/platzky/locale/pl/LC_MESSAGES/messages.po +0 -0
  39. {platzky-1.3.1 → platzky-1.4.1}/platzky/models.py +0 -0
  40. {platzky-1.3.1 → platzky-1.4.1}/platzky/notifier.py +0 -0
  41. {platzky-1.3.1 → platzky-1.4.1}/platzky/plugin/plugin.py +0 -0
  42. {platzky-1.3.1 → platzky-1.4.1}/platzky/plugin/plugin_loader.py +0 -0
  43. {platzky-1.3.1 → platzky-1.4.1}/platzky/static/blog.css +0 -0
  44. {platzky-1.3.1 → platzky-1.4.1}/platzky/static/styles.css +0 -0
  45. {platzky-1.3.1 → platzky-1.4.1}/platzky/telemetry.py +0 -0
  46. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/404.html +0 -0
  47. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/base.html +0 -0
  48. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/blog.html +0 -0
  49. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/body_meta.html +0 -0
  50. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/dynamic_css.html +0 -0
  51. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/feed.xml +0 -0
  52. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/head_meta.html +0 -0
  53. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/page.html +0 -0
  54. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/post.html +0 -0
  55. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/robots.txt +0 -0
  56. {platzky-1.3.1 → platzky-1.4.1}/platzky/templates/sitemap.xml +0 -0
  57. {platzky-1.3.1 → platzky-1.4.1}/platzky/www_handler.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: platzky
3
- Version: 1.3.1
3
+ Version: 1.4.1
4
4
  Summary: Not only blog engine
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -0,0 +1,9 @@
1
+ from platzky.engine import Engine as Engine
2
+ from platzky.feature_flags import FakeLogin as FakeLogin
3
+ from platzky.feature_flags import FeatureFlag as FeatureFlag
4
+ from platzky.feature_flags import all_flags as all_flags
5
+ from platzky.feature_flags import build_flag_set as build_flag_set
6
+ from platzky.feature_flags import parse_flags as parse_flags
7
+ from platzky.feature_flags_wrapper import FeatureFlagSet as FeatureFlagSet
8
+ from platzky.platzky import create_app_from_config as create_app_from_config
9
+ from platzky.platzky import create_engine as create_engine
@@ -20,7 +20,6 @@ def create_admin_blueprint(
20
20
  Returns:
21
21
  Configured Flask Blueprint for admin panel
22
22
  """
23
- # …rest of the function…
24
23
  admin = Blueprint(
25
24
  "admin",
26
25
  __name__,
@@ -49,7 +48,7 @@ def create_admin_blueprint(
49
48
  Returns:
50
49
  Rendered login page if not authenticated, admin panel if authenticated
51
50
  """
52
- user = session.get("user", None)
51
+ user = session.get("user")
53
52
 
54
53
  if not user:
55
54
  return render_template("login.html", login_methods=login_methods)
@@ -31,11 +31,10 @@ def create_blog_blueprint(db: DB, blog_prefix: str, locale_func: Callable[[], st
31
31
  Returns:
32
32
  Configured Flask Blueprint for blog functionality
33
33
  """
34
- url_prefix = blog_prefix
35
34
  blog = Blueprint(
36
35
  "blog",
37
36
  __name__,
38
- url_prefix=url_prefix,
37
+ url_prefix=blog_prefix,
39
38
  template_folder=f"{dirname(__file__)}/../templates",
40
39
  )
41
40
 
@@ -159,7 +158,7 @@ def create_blog_blueprint(db: DB, blog_prefix: str, locale_func: Callable[[], st
159
158
  Rendered HTML template of the page
160
159
  """
161
160
  page = _get_content_or_404(db.get_page, page_slug)
162
- cover_image_url = page.coverImage.url if page.coverImage.url else None
161
+ cover_image_url = (page.coverImage.url or None) if page.coverImage else None
163
162
  return render_template("page.html", page=page, cover_image=cover_image_url)
164
163
 
165
164
  @blog.route("/tag/<path:tag>", methods=["GET"])
@@ -9,18 +9,14 @@ import typing as t
9
9
  import yaml
10
10
  from pydantic import BaseModel, ConfigDict, Field, field_validator
11
11
 
12
- from .attachment.constants import BLOCKED_EXTENSIONS, DEFAULT_MAX_ATTACHMENT_SIZE
13
- from .db.db import DBConfig
14
- from .db.db_loader import get_db_module
12
+ from platzky.attachment.constants import BLOCKED_EXTENSIONS, DEFAULT_MAX_ATTACHMENT_SIZE
13
+ from platzky.db.db import DBConfig
14
+ from platzky.db.db_loader import get_db_module
15
+ from platzky.feature_flags import build_flag_set
16
+ from platzky.feature_flags_wrapper import FeatureFlagSet
15
17
 
16
18
 
17
- class StrictBaseModel(BaseModel):
18
- """Base model with immutable (frozen) configuration."""
19
-
20
- model_config = ConfigDict(frozen=True)
21
-
22
-
23
- class LanguageConfig(StrictBaseModel):
19
+ class LanguageConfig(BaseModel):
24
20
  """Configuration for a single language.
25
21
 
26
22
  Attributes:
@@ -30,10 +26,12 @@ class LanguageConfig(StrictBaseModel):
30
26
  domain: Optional domain specific to this language
31
27
  """
32
28
 
33
- name: str = Field(alias="name")
34
- flag: str = Field(alias="flag")
35
- country: str = Field(alias="country")
36
- domain: t.Optional[str] = Field(default=None, alias="domain")
29
+ model_config = ConfigDict(frozen=True)
30
+
31
+ name: str
32
+ flag: str
33
+ country: str
34
+ domain: t.Optional[str] = None
37
35
 
38
36
 
39
37
  Languages = dict[str, LanguageConfig]
@@ -64,7 +62,7 @@ def languages_dict(languages: Languages) -> LanguagesMapping:
64
62
  }
65
63
 
66
64
 
67
- class TelemetryConfig(StrictBaseModel):
65
+ class TelemetryConfig(BaseModel):
68
66
  """OpenTelemetry configuration for application tracing.
69
67
 
70
68
  Attributes:
@@ -79,15 +77,17 @@ class TelemetryConfig(StrictBaseModel):
79
77
  instrument_logging: Enable automatic logging instrumentation (default: True)
80
78
  """
81
79
 
82
- enabled: bool = Field(default=False, alias="enabled")
83
- endpoint: t.Optional[str] = Field(default=None, alias="endpoint")
84
- console_export: bool = Field(default=False, alias="console_export")
85
- timeout: int = Field(default=10, alias="timeout", gt=0)
86
- deployment_environment: t.Optional[str] = Field(default=None, alias="deployment_environment")
87
- service_instance_id: t.Optional[str] = Field(default=None, alias="service_instance_id")
88
- flush_on_request: bool = Field(default=True, alias="flush_on_request")
89
- flush_timeout_ms: int = Field(default=5000, alias="flush_timeout_ms", gt=0)
90
- instrument_logging: bool = Field(default=True, alias="instrument_logging")
80
+ model_config = ConfigDict(frozen=True)
81
+
82
+ enabled: bool = False
83
+ endpoint: t.Optional[str] = None
84
+ console_export: bool = False
85
+ timeout: int = Field(default=10, gt=0)
86
+ deployment_environment: t.Optional[str] = None
87
+ service_instance_id: t.Optional[str] = None
88
+ flush_on_request: bool = True
89
+ flush_timeout_ms: int = Field(default=5000, gt=0)
90
+ instrument_logging: bool = True
91
91
 
92
92
  @field_validator("endpoint")
93
93
  @classmethod
@@ -165,7 +165,7 @@ _DEFAULT_ALLOWED_MIME_TYPES: frozenset[str] = frozenset(
165
165
  )
166
166
 
167
167
 
168
- class AttachmentConfig(StrictBaseModel):
168
+ class AttachmentConfig(BaseModel):
169
169
  """Configuration for attachment handling.
170
170
 
171
171
  Attributes:
@@ -181,6 +181,8 @@ class AttachmentConfig(StrictBaseModel):
181
181
  Files without extensions are always blocked when allowed_extensions is set.
182
182
  """
183
183
 
184
+ model_config = ConfigDict(frozen=True)
185
+
184
186
  allowed_mime_types: frozenset[str] = Field(default=_DEFAULT_ALLOWED_MIME_TYPES)
185
187
  validate_content: bool = Field(default=True)
186
188
  allow_unrecognized_content: bool = Field(default=False)
@@ -220,7 +222,7 @@ class AttachmentConfig(StrictBaseModel):
220
222
  )
221
223
 
222
224
 
223
- class Config(StrictBaseModel):
225
+ class Config(BaseModel):
224
226
  """Main application configuration.
225
227
 
226
228
  Attributes:
@@ -239,6 +241,8 @@ class Config(StrictBaseModel):
239
241
  attachment: Attachment handling configuration
240
242
  """
241
243
 
244
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
245
+
242
246
  app_name: str = Field(alias="APP_NAME")
243
247
  secret_key: str = Field(alias="SECRET_KEY")
244
248
  db: DBConfig = Field(alias="DB")
@@ -252,10 +256,25 @@ class Config(StrictBaseModel):
252
256
  )
253
257
  debug: bool = Field(default=False, alias="DEBUG")
254
258
  testing: bool = Field(default=False, alias="TESTING")
255
- feature_flags: t.Optional[dict[str, bool]] = Field(default=None, alias="FEATURE_FLAGS")
259
+ feature_flags: FeatureFlagSet = Field(
260
+ default_factory=build_flag_set,
261
+ alias="FEATURE_FLAGS",
262
+ )
256
263
  telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig, alias="TELEMETRY")
257
264
  attachment: AttachmentConfig = Field(default_factory=AttachmentConfig, alias="ATTACHMENT")
258
265
 
266
+ @field_validator("feature_flags", mode="before")
267
+ @classmethod
268
+ def validate_feature_flags(cls, v: FeatureFlagSet | dict[str, bool] | None) -> FeatureFlagSet:
269
+ """Coerce dict or None into a FeatureFlagSet."""
270
+ if isinstance(v, FeatureFlagSet):
271
+ return v
272
+ if isinstance(v, dict):
273
+ return build_flag_set(v)
274
+ if v is None:
275
+ return build_flag_set()
276
+ return v
277
+
259
278
  @classmethod
260
279
  def model_validate(
261
280
  cls,
@@ -267,6 +286,9 @@ class Config(StrictBaseModel):
267
286
  ) -> "Config":
268
287
  """Validate and construct Config from dictionary.
269
288
 
289
+ Parses the raw FEATURE_FLAGS dict into a frozenset of enabled
290
+ FeatureFlag types via ``parse_flags()``.
291
+
270
292
  Args:
271
293
  obj: Configuration dictionary
272
294
  strict: Enable strict validation
@@ -276,8 +298,14 @@ class Config(StrictBaseModel):
276
298
  Returns:
277
299
  Validated Config instance
278
300
  """
279
- db_cfg_type = get_db_module(obj["DB"]["TYPE"]).db_config_type()
280
- obj["DB"] = db_cfg_type.model_validate(obj["DB"])
301
+ try:
302
+ db_section = obj["DB"]
303
+ db_type = db_section["TYPE"]
304
+ except KeyError as e:
305
+ raise ValueError(f"Missing required config key: {e}. DB.TYPE is required.") from e
306
+ db_cfg_type = get_db_module(db_type).db_config_type()
307
+ obj["DB"] = db_cfg_type.model_validate(db_section)
308
+
281
309
  return super().model_validate(
282
310
  obj, strict=strict, from_attributes=from_attributes, context=context
283
311
  )
@@ -52,13 +52,7 @@ def get_db(config: dict[str, Any]) -> "GithubJsonDb":
52
52
  Returns:
53
53
  Configured GitHub JSON database instance
54
54
  """
55
- github_json_db_config = GithubJsonDbConfig.model_validate(config)
56
- return GithubJsonDb(
57
- github_json_db_config.github_token,
58
- github_json_db_config.repo_name,
59
- github_json_db_config.branch_name,
60
- github_json_db_config.path_to_file,
61
- )
55
+ return db_from_config(GithubJsonDbConfig.model_validate(config))
62
56
 
63
57
 
64
58
  class GithubJsonDb(JsonDB):
@@ -50,8 +50,7 @@ def get_db(config: dict[str, Any]) -> "GoogleJsonDb":
50
50
  Returns:
51
51
  Configured Google Cloud Storage JSON database instance
52
52
  """
53
- google_json_db_config = GoogleJsonDbConfig.model_validate(config)
54
- return GoogleJsonDb(google_json_db_config.bucket_name, google_json_db_config.source_blob_name)
53
+ return db_from_config(GoogleJsonDbConfig.model_validate(config))
55
54
 
56
55
 
57
56
  def get_blob(bucket_name: str, source_blob_name: str) -> "Blob":
@@ -29,18 +29,6 @@ class GraphQlDbConfig(DBConfig):
29
29
  token: str = Field(alias="CMS_TOKEN")
30
30
 
31
31
 
32
- def get_db(config: GraphQlDbConfig) -> "GraphQL":
33
- """Get a GraphQL database instance from configuration.
34
-
35
- Args:
36
- config: GraphQL database configuration
37
-
38
- Returns:
39
- Configured GraphQL database instance
40
- """
41
- return GraphQL(config.endpoint, config.token)
42
-
43
-
44
32
  def db_from_config(config: GraphQlDbConfig) -> "GraphQL":
45
33
  """Create a GraphQL database instance from configuration.
46
34
 
@@ -53,6 +41,10 @@ def db_from_config(config: GraphQlDbConfig) -> "GraphQL":
53
41
  return GraphQL(config.endpoint, config.token)
54
42
 
55
43
 
44
+ # Legacy alias retained for backward compatibility
45
+ get_db = db_from_config
46
+
47
+
56
48
  def _standardize_comment(
57
49
  comment: dict[str, Any],
58
50
  ) -> dict[str, Any]:
@@ -33,8 +33,7 @@ def get_db(config: dict[str, Any]) -> "Json":
33
33
  Returns:
34
34
  Configured JSON database instance
35
35
  """
36
- json_db_config = JsonDbConfig.model_validate(config)
37
- return Json(json_db_config.data)
36
+ return db_from_config(JsonDbConfig.model_validate(config))
38
37
 
39
38
 
40
39
  def db_from_config(config: JsonDbConfig) -> "Json":
@@ -129,12 +128,10 @@ class Json(DB):
129
128
  pages = self._get_site_content().get("pages")
130
129
  if pages is None:
131
130
  raise ValueError("Pages data is missing")
132
- list_of_pages = (page for page in pages if page["slug"] == slug)
133
- wanted_page = next(list_of_pages, None)
131
+ wanted_page = next((page for page in pages if page["slug"] == slug), None)
134
132
  if wanted_page is None:
135
133
  raise ValueError(f"Page with slug {slug} not found")
136
- page = Page.model_validate(wanted_page)
137
- return page
134
+ return Page.model_validate(wanted_page)
138
135
 
139
136
  def get_menu_items_in_lang(self, lang: str) -> list[MenuItem]:
140
137
  """Retrieve menu items for a specific language.
@@ -146,10 +143,8 @@ class Json(DB):
146
143
  List of MenuItem objects
147
144
  """
148
145
  menu_items_raw = self._get_site_content().get("menu_items", {})
149
- items_in_lang = menu_items_raw.get(lang, {})
150
-
151
- menu_items_list = [MenuItem.model_validate(x) for x in items_in_lang]
152
- return menu_items_list
146
+ items_in_lang = menu_items_raw.get(lang, [])
147
+ return [MenuItem.model_validate(x) for x in items_in_lang]
153
148
 
154
149
  def get_posts_by_tag(self, tag: str, lang: str) -> list[Post]:
155
150
  """Retrieve posts filtered by tag and language.
@@ -236,12 +231,11 @@ class Json(DB):
236
231
  "date": now_utc,
237
232
  }
238
233
 
239
- post_index = next(
240
- i
241
- for i in range(len(self._get_site_content()["posts"]))
242
- if self._get_site_content()["posts"][i]["slug"] == post_slug
243
- )
244
- self._get_site_content()["posts"][post_index]["comments"].append(comment_data)
234
+ posts = self._get_site_content()["posts"]
235
+ post = next((p for p in posts if p["slug"] == post_slug), None)
236
+ if post is None:
237
+ raise ValueError(f"Post with slug {post_slug} not found")
238
+ post["comments"].append(comment_data)
245
239
 
246
240
  def get_plugins_data(self) -> list[dict[str, Any]]:
247
241
  """Retrieve configuration data for all plugins.
@@ -33,8 +33,7 @@ def get_db(config: dict[str, Any]) -> "JsonFile":
33
33
  Returns:
34
34
  Configured JSON file database instance
35
35
  """
36
- json_file_db_config = JsonFileDbConfig.model_validate(config)
37
- return JsonFile(json_file_db_config.path)
36
+ return db_from_config(JsonFileDbConfig.model_validate(config))
38
37
 
39
38
 
40
39
  def db_from_config(config: JsonFileDbConfig) -> "JsonFile":
@@ -37,8 +37,7 @@ def get_db(config: dict[str, Any]) -> "MongoDB":
37
37
  Returns:
38
38
  Configured MongoDB database instance
39
39
  """
40
- mongodb_config = MongoDbConfig.model_validate(config)
41
- return MongoDB(mongodb_config.connection_string, mongodb_config.database_name)
40
+ return db_from_config(MongoDbConfig.model_validate(config))
42
41
 
43
42
 
44
43
  def db_from_config(config: MongoDbConfig) -> "MongoDB":
@@ -78,6 +77,10 @@ class MongoDB(DB):
78
77
  self.menu_items: Collection[Any] = self.db.menu_items
79
78
  self.plugins: Collection[Any] = self.db.plugins
80
79
 
80
+ def _get_site_config(self) -> dict[str, Any] | None:
81
+ """Retrieve the site configuration document."""
82
+ return self.site_content.find_one({"_id": "config"})
83
+
81
84
  def get_app_description(self, lang: str) -> str:
82
85
  """Retrieve the application description for a specific language.
83
86
 
@@ -87,9 +90,9 @@ class MongoDB(DB):
87
90
  Returns:
88
91
  Application description text or empty string if not found
89
92
  """
90
- site_content = self.site_content.find_one({"_id": "config"})
91
- if site_content and "app_description" in site_content:
92
- return site_content["app_description"].get(lang, "")
93
+ site_config = self._get_site_config()
94
+ if site_config and "app_description" in site_config:
95
+ return site_config["app_description"].get(lang, "")
93
96
  return ""
94
97
 
95
98
  def get_all_posts(self, lang: str) -> list[Post]:
@@ -193,10 +196,8 @@ class MongoDB(DB):
193
196
  Returns:
194
197
  Logo image URL or empty string if not found
195
198
  """
196
- site_content = self.site_content.find_one({"_id": "config"})
197
- if site_content:
198
- return site_content.get("logo_url", "")
199
- return ""
199
+ site_config = self._get_site_config()
200
+ return site_config.get("logo_url", "") if site_config else ""
200
201
 
201
202
  def get_favicon_url(self) -> str:
202
203
  """Retrieve the URL of the application favicon.
@@ -204,10 +205,8 @@ class MongoDB(DB):
204
205
  Returns:
205
206
  Favicon URL or empty string if not found
206
207
  """
207
- site_content = self.site_content.find_one({"_id": "config"})
208
- if site_content:
209
- return site_content.get("favicon_url", "")
210
- return ""
208
+ site_config = self._get_site_config()
209
+ return site_config.get("favicon_url", "") if site_config else ""
211
210
 
212
211
  def get_primary_color(self) -> str:
213
212
  """Retrieve the primary color for the application theme.
@@ -215,10 +214,8 @@ class MongoDB(DB):
215
214
  Returns:
216
215
  Primary color value, defaults to 'white'
217
216
  """
218
- site_content = self.site_content.find_one({"_id": "config"})
219
- if site_content:
220
- return site_content.get("primary_color", "white")
221
- return "white"
217
+ site_config = self._get_site_config()
218
+ return site_config.get("primary_color", "white") if site_config else "white"
222
219
 
223
220
  def get_secondary_color(self) -> str:
224
221
  """Retrieve the secondary color for the application theme.
@@ -226,10 +223,8 @@ class MongoDB(DB):
226
223
  Returns:
227
224
  Secondary color value, defaults to 'navy'
228
225
  """
229
- site_content = self.site_content.find_one({"_id": "config"})
230
- if site_content:
231
- return site_content.get("secondary_color", "navy")
232
- return "navy"
226
+ site_config = self._get_site_config()
227
+ return site_config.get("secondary_color", "navy") if site_config else "navy"
233
228
 
234
229
  def get_plugins_data(self) -> list[dict[str, Any]]:
235
230
  """Retrieve configuration data for all plugins.
@@ -248,10 +243,8 @@ class MongoDB(DB):
248
243
  Returns:
249
244
  Font name or empty string if not configured
250
245
  """
251
- site_content = self.site_content.find_one({"_id": "config"})
252
- if site_content:
253
- return site_content.get("font", "")
254
- return ""
246
+ site_config = self._get_site_config()
247
+ return site_config.get("font", "") if site_config else ""
255
248
 
256
249
  def health_check(self) -> None:
257
250
  """Perform a health check on the MongoDB database.
@@ -0,0 +1,9 @@
1
+ """Debug and development utilities.
2
+
3
+ This package contains tools that should only be used in development
4
+ or testing environments, never in production.
5
+ """
6
+
7
+ from platzky.debug.blueprint import DebugBlueprint, DebugBlueprintProductionError
8
+
9
+ __all__ = ["DebugBlueprint", "DebugBlueprintProductionError"]
@@ -0,0 +1,51 @@
1
+ """Debug-only Platzky blueprint."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ from flask import Blueprint
7
+ from flask.sansio.app import App
8
+ from typing_extensions import override
9
+
10
+ _FLASK_DEBUG_TRUTHY = {"1", "true", "yes"}
11
+
12
+
13
+ def _is_flask_debug_env() -> bool:
14
+ """Check if FLASK_DEBUG environment variable indicates debug mode.
15
+
16
+ Flask CLI sets this env var before calling the app factory, so it
17
+ reflects ``--debug`` even though ``app.config["DEBUG"]`` may not yet.
18
+ """
19
+ return os.environ.get("FLASK_DEBUG", "").lower() in _FLASK_DEBUG_TRUTHY
20
+
21
+
22
+ class DebugBlueprintProductionError(RuntimeError):
23
+ """Raised when a DebugBlueprint is registered on a production app."""
24
+
25
+ def __init__(self, blueprint_name: str) -> None:
26
+ super().__init__(
27
+ f"SECURITY ERROR: Cannot register DebugBlueprint '{blueprint_name}' in production. "
28
+ f"DEBUG and TESTING are both False. "
29
+ f"Set DEBUG: true or TESTING: true in your config, "
30
+ f"or use flask --debug."
31
+ )
32
+
33
+
34
+ class DebugBlueprint(Blueprint):
35
+ """A Blueprint that can only be registered on apps in debug/testing mode.
36
+
37
+ Raises DebugBlueprintProductionError during registration if the app is not
38
+ in debug or testing mode. This provides a structural guarantee that debug-only
39
+ routes cannot be accidentally enabled in production.
40
+
41
+ Checks ``app.config["DEBUG"]``, ``app.config["TESTING"]``, and the
42
+ ``FLASK_DEBUG`` environment variable (set by ``flask --debug`` before
43
+ the app factory is called).
44
+ """
45
+
46
+ @override
47
+ def register(self, app: App, options: dict[str, Any]) -> None:
48
+ """Register the blueprint, but only if app is in debug/testing mode."""
49
+ if not (app.config.get("DEBUG") or app.config.get("TESTING") or _is_flask_debug_env()):
50
+ raise DebugBlueprintProductionError(self.name)
51
+ super().register(app, options)
@@ -5,14 +5,15 @@ WARNING: This module provides fake login functionality and should NEVER be used
5
5
  environments as it bypasses proper authentication and authorization controls.
6
6
  """
7
7
 
8
- import os
9
8
  from collections.abc import Callable
10
9
 
11
- from flask import Blueprint, flash, redirect, render_template_string, session, url_for
10
+ from flask import flash, redirect, render_template_string, session, url_for
12
11
  from flask_wtf import FlaskForm
13
12
  from markupsafe import Markup
14
13
  from werkzeug.wrappers import Response
15
14
 
15
+ from platzky.debug.blueprint import DebugBlueprint
16
+
16
17
  ROLE_ADMIN = "admin"
17
18
  ROLE_NONADMIN = "nonadmin"
18
19
  VALID_ROLES = [ROLE_ADMIN, ROLE_NONADMIN]
@@ -33,8 +34,8 @@ def get_fake_login_html() -> Callable[[], str]:
33
34
  """Return a callable that generates HTML for fake login buttons."""
34
35
 
35
36
  def generate_html() -> str:
36
- admin_url = url_for("admin.handle_fake_login", role="admin")
37
- nonadmin_url = url_for("admin.handle_fake_login", role="nonadmin")
37
+ admin_url = url_for("fake_login.handle_fake_login", role="admin")
38
+ nonadmin_url = url_for("fake_login.handle_fake_login", role="nonadmin")
38
39
 
39
40
  # Create a form instance to get the CSRF token
40
41
  form = FakeLoginForm()
@@ -72,24 +73,18 @@ def get_fake_login_html() -> Callable[[], str]:
72
73
  return generate_html
73
74
 
74
75
 
75
- def setup_fake_login_routes(admin_blueprint: Blueprint) -> Blueprint:
76
- """Add fake login routes to the provided admin_blueprint."""
76
+ def create_fake_login_blueprint() -> DebugBlueprint:
77
+ """Create a DebugBlueprint with fake login routes.
77
78
 
78
- env = os.environ
79
- is_testing = "PYTEST_CURRENT_TEST" in env.keys() or env.get("FLASK_DEBUG") in (
80
- "1",
81
- "true",
82
- "True",
83
- True,
84
- )
79
+ The returned blueprint will raise RuntimeError if registered on an app
80
+ that is not in debug or testing mode.
85
81
 
86
- if not is_testing:
87
- raise RuntimeError(
88
- "SECURITY ERROR: Fake login routes are enabled outside of a testing environment! "
89
- "This functionality must only be used during development or testing."
90
- )
82
+ Returns:
83
+ DebugBlueprint with fake login routes at /admin/fake-login/<role>.
84
+ """
85
+ bp = DebugBlueprint("fake_login", __name__, url_prefix="/admin")
91
86
 
92
- @admin_blueprint.route("/fake-login/<role>", methods=["POST"])
87
+ @bp.route("/fake-login/<role>", methods=["POST"])
93
88
  def handle_fake_login(role: str) -> Response:
94
89
  form = FakeLoginForm()
95
90
  if form.validate_on_submit() and role in VALID_ROLES:
@@ -102,4 +97,4 @@ def setup_fake_login_routes(admin_blueprint: Blueprint) -> Blueprint:
102
97
  flash(f"Invalid role: {role}. Must be one of: {', '.join(VALID_ROLES)}", "error")
103
98
  return redirect(url_for("admin.admin_panel_home"))
104
99
 
105
- return admin_blueprint
100
+ return bp
@@ -13,6 +13,7 @@ from flask_babel import Babel
13
13
  from platzky.attachment import AttachmentProtocol, create_attachment_class
14
14
  from platzky.config import Config
15
15
  from platzky.db.db import DB
16
+ from platzky.feature_flags import FeatureFlag
16
17
  from platzky.models import CmsModule
17
18
  from platzky.notifier import Notifier, NotifierWithAttachments
18
19
 
@@ -35,6 +36,7 @@ class Engine(Flask):
35
36
  """
36
37
  super().__init__(import_name)
37
38
  self.config.from_mapping(config.model_dump(by_alias=True))
39
+ self.config["FEATURE_FLAGS"] = config.feature_flags
38
40
  self.db = db
39
41
  self.Attachment: type[AttachmentProtocol] = create_attachment_class(config.attachment)
40
42
  self.notifiers: list[Notifier] = []
@@ -87,7 +89,7 @@ class Engine(Flask):
87
89
  """
88
90
  self.notifiers_with_attachments.append(notifier)
89
91
 
90
- def add_cms_module(self, module: CmsModule):
92
+ def add_cms_module(self, module: CmsModule) -> None:
91
93
  """Add a CMS module to the modules list."""
92
94
  self.cms_modules.append(module)
93
95
 
@@ -95,11 +97,11 @@ class Engine(Flask):
95
97
  def add_login_method(self, login_method: Callable[[], str]) -> None:
96
98
  self.login_methods.append(login_method)
97
99
 
98
- def add_dynamic_body(self, body: str):
100
+ def add_dynamic_body(self, body: str) -> None:
99
101
  self.dynamic_body += body
100
102
 
101
- def add_dynamic_head(self, body: str):
102
- self.dynamic_head += body
103
+ def add_dynamic_head(self, head: str) -> None:
104
+ self.dynamic_head += head
103
105
 
104
106
  def get_locale(self) -> str:
105
107
  languages = self.config.get("LANGUAGES", {}).keys()
@@ -113,6 +115,19 @@ class Engine(Flask):
113
115
  session["language"] = lang
114
116
  return lang
115
117
 
118
+ def is_enabled(self, flag: FeatureFlag) -> bool:
119
+ """Check whether a feature flag is enabled.
120
+
121
+ This is the primary API for flag checks.
122
+
123
+ Args:
124
+ flag: A FeatureFlag instance.
125
+
126
+ Returns:
127
+ True if the flag is enabled.
128
+ """
129
+ return flag in self.config["FEATURE_FLAGS"]
130
+
116
131
  def add_health_check(self, name: str, check_function: Callable[[], None]) -> None:
117
132
  """Register a health check function"""
118
133
  if not callable(check_function):
@@ -0,0 +1,141 @@
1
+ """Feature flags system with instance-based registration.
2
+
3
+ Flags are created as instances of ``FeatureFlag``. Each instance is
4
+ automatically registered and discovered via ``all_flags()``. The primary
5
+ API is ``engine.is_enabled(flag_instance)``.
6
+
7
+ Example::
8
+
9
+ CategoriesHelp = FeatureFlag(alias="CATEGORIES_HELP")
10
+
11
+ # Usage
12
+ app.is_enabled(CategoriesHelp) # True/False
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from platzky.feature_flags_wrapper import FeatureFlagSet
21
+
22
+ _registry: set[FeatureFlag] = set()
23
+
24
+
25
+ class FeatureFlag:
26
+ """A feature flag.
27
+
28
+ Identity is based solely on ``alias``: two flags with the same alias
29
+ are considered equal regardless of ``default`` or ``description``.
30
+ Aliases are expected to be unique across the application.
31
+
32
+ Args:
33
+ alias: The YAML/dict key for this flag.
34
+ default: Whether the flag is enabled by default.
35
+ description: Human-readable description.
36
+
37
+ Example::
38
+
39
+ FakeLogin = FeatureFlag(
40
+ alias="FAKE_LOGIN",
41
+ default=False,
42
+ description="Enable fake login. Never in production.",
43
+ )
44
+ """
45
+
46
+ __slots__ = ("alias", "default", "description", "production_warning")
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ alias: str,
52
+ default: bool = False,
53
+ description: str = "",
54
+ production_warning: bool = False,
55
+ register: bool = True,
56
+ ) -> None:
57
+ if not alias:
58
+ raise ValueError("FeatureFlag requires a non-empty 'alias'")
59
+ self.alias = alias
60
+ self.default = default
61
+ self.description = description
62
+ self.production_warning = production_warning
63
+ if register:
64
+ _registry.add(self)
65
+
66
+ def __repr__(self) -> str:
67
+ return f"FeatureFlag(alias={self.alias!r})"
68
+
69
+ def __hash__(self) -> int:
70
+ return hash(self.alias)
71
+
72
+ def __eq__(self, other: object) -> bool:
73
+ if isinstance(other, FeatureFlag):
74
+ return self.alias == other.alias
75
+ return NotImplemented
76
+
77
+
78
+ FakeLogin = FeatureFlag(
79
+ alias="FAKE_LOGIN",
80
+ default=False,
81
+ description="Enable fake login for development. WARNING: Never enable in production.",
82
+ production_warning=True,
83
+ )
84
+
85
+
86
+ def all_flags() -> frozenset[FeatureFlag]:
87
+ """Return all registered feature flags.
88
+
89
+ Note: The returned frozenset has no guaranteed iteration order.
90
+ Use ``sorted(all_flags(), key=lambda f: f.alias)`` when
91
+ deterministic ordering is needed (e.g., documentation generation).
92
+ """
93
+ return frozenset(_registry)
94
+
95
+
96
+ def unregister(flag: FeatureFlag) -> None:
97
+ """Remove a flag from the registry."""
98
+ _registry.discard(flag)
99
+
100
+
101
+ def clear_registry() -> None:
102
+ """Remove all flags from the registry. Intended for test isolation."""
103
+ _registry.clear()
104
+
105
+
106
+ def parse_flags(
107
+ raw_data: dict[str, bool] | None = None,
108
+ ) -> frozenset[FeatureFlag]:
109
+ """Build a frozenset of *enabled* flags from raw config data.
110
+
111
+ Uses ``all_flags()`` for discovery. Unknown keys in *raw_data* are
112
+ silently ignored.
113
+
114
+ Args:
115
+ raw_data: Dict of flag alias -> value from config / YAML.
116
+
117
+ Returns:
118
+ A frozenset containing the enabled FeatureFlag instances.
119
+ """
120
+ if raw_data is None:
121
+ raw_data = {}
122
+
123
+ return frozenset(flag for flag in all_flags() if raw_data.get(flag.alias, flag.default))
124
+
125
+
126
+ def build_flag_set(raw_data: dict[str, bool] | None = None) -> FeatureFlagSet:
127
+ """Build a FeatureFlagSet from raw config data.
128
+
129
+ Preserves ALL keys (including unregistered ones) for backward
130
+ compatibility with consumers that use dict-like access.
131
+ """
132
+ from platzky.feature_flags_wrapper import FeatureFlagSet
133
+
134
+ if raw_data is None:
135
+ raw_data = {}
136
+
137
+ enabled_flags = frozenset(
138
+ flag for flag in all_flags() if raw_data.get(flag.alias, flag.default)
139
+ )
140
+
141
+ return FeatureFlagSet(enabled_flags, raw_data)
@@ -0,0 +1,93 @@
1
+ """Backward-compatible wrapper for feature flags.
2
+
3
+ .. deprecated:: 1.4.0
4
+ Dict-like access is deprecated. Use typed FeatureFlag instances
5
+ with engine.is_enabled(flag) instead.
6
+ """
7
+
8
+ import warnings
9
+
10
+ import deprecation
11
+
12
+ from platzky.feature_flags import FeatureFlag
13
+
14
+ _MIGRATION_MSG = (
15
+ "Dict-like access to feature flags is deprecated. "
16
+ "Define a FeatureFlag instance and use engine.is_enabled(flag) instead. "
17
+ "See: https://platzky.readthedocs.io/en/latest/config.html#feature-flags"
18
+ )
19
+
20
+ _DEPRECATION_WARNING = deprecation.DeprecatedWarning(
21
+ "feature_flags dict access",
22
+ "1.4.0",
23
+ "2.0.0",
24
+ _MIGRATION_MSG,
25
+ )
26
+
27
+
28
+ def _warn_dict_access() -> None:
29
+ warnings.warn(_DEPRECATION_WARNING, stacklevel=3)
30
+
31
+
32
+ class FeatureFlagSet(dict[str, bool]):
33
+ """Backward-compatible feature flag collection (dict subclass).
34
+
35
+ .. deprecated:: 1.4.0
36
+ Dict-like access (.get, .KEY, [key]) is deprecated.
37
+ Migrate to typed FeatureFlag + engine.is_enabled().
38
+
39
+ Dual-mode container:
40
+ - ``FeatureFlag in flag_set`` -- typed membership (engine.is_enabled). Intended API.
41
+ - ``flag_set.get("KEY")`` -- deprecated dict access via dict inheritance.
42
+ - ``flag_set.KEY`` -- deprecated Jinja2 attribute access.
43
+ """
44
+
45
+ _enabled_flags: frozenset[FeatureFlag]
46
+
47
+ def __init__(
48
+ self,
49
+ enabled_flags: frozenset[FeatureFlag],
50
+ raw_data: dict[str, bool],
51
+ ) -> None:
52
+ super().__init__(raw_data)
53
+ # Use object.__setattr__ to bypass __getattr__ override
54
+ object.__setattr__(self, "_enabled_flags", enabled_flags)
55
+
56
+ def __getitem__(self, key: str) -> bool:
57
+ """Dict bracket access with deprecation warning."""
58
+ _warn_dict_access()
59
+ return super().__getitem__(key)
60
+
61
+ def get(self, key: str, default: bool | None = None) -> bool | None: # type: ignore[override]
62
+ """Dict .get() access with deprecation warning."""
63
+ _warn_dict_access()
64
+ return super().get(key, default)
65
+
66
+ def _raise_immutable(self, *_args: object, **_kwargs: object) -> None:
67
+ raise TypeError("FeatureFlagSet is immutable")
68
+
69
+ __setitem__ = _raise_immutable
70
+ __delitem__ = _raise_immutable
71
+ pop = _raise_immutable # type: ignore[assignment]
72
+ update = _raise_immutable
73
+ clear = _raise_immutable
74
+ setdefault = _raise_immutable # type: ignore[assignment]
75
+ __ior__ = _raise_immutable # type: ignore[assignment]
76
+
77
+ def __contains__(self, item: object) -> bool:
78
+ """Support both FeatureFlag membership and string key lookup."""
79
+ if isinstance(item, FeatureFlag):
80
+ return item in self._enabled_flags
81
+ return super().__contains__(item)
82
+
83
+ def __getattr__(self, name: str) -> bool:
84
+ """Jinja2 dot-notation access: ``{{ feature_flags.SOME_FLAG }}``."""
85
+ try:
86
+ return self[name]
87
+ except KeyError:
88
+ raise AttributeError(f"'{type(self).__name__}' has no attribute {name!r}") from None
89
+
90
+ @property
91
+ def enabled_flags(self) -> frozenset[FeatureFlag]:
92
+ """The set of enabled typed FeatureFlag instances."""
93
+ return self._enabled_flags
@@ -16,6 +16,7 @@ from platzky.config import (
16
16
  from platzky.db.db import DB
17
17
  from platzky.db.db_loader import get_db
18
18
  from platzky.engine import Engine
19
+ from platzky.feature_flags import FakeLogin
19
20
  from platzky.plugin.plugin_loader import plugify
20
21
  from platzky.seo import seo
21
22
  from platzky.www_handler import redirect_nonwww_to_www, redirect_www_to_nonwww
@@ -129,17 +130,6 @@ def create_engine(config: Config, db: DB) -> Engine:
129
130
  redirect_url = _get_safe_redirect_url(request.referrer, request.host)
130
131
  return redirect(redirect_url)
131
132
 
132
- def url_link(x: str) -> str:
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)
142
-
143
133
  @app.context_processor
144
134
  def utils() -> dict[str, t.Any]:
145
135
  """Provide utility variables and functions to all templates.
@@ -150,8 +140,8 @@ def create_engine(config: Config, db: DB) -> Engine:
150
140
  """
151
141
  locale = app.get_locale()
152
142
  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 ""
143
+ flag = lang.flag if lang else ""
144
+ country = lang.country if lang else ""
155
145
  return {
156
146
  "app_name": config.app_name,
157
147
  "app_description": app.db.get_app_description(locale) or config.app_name,
@@ -159,7 +149,7 @@ def create_engine(config: Config, db: DB) -> Engine:
159
149
  "current_flag": flag,
160
150
  "current_lang_country": country,
161
151
  "current_language": locale,
162
- "url_link": url_link,
152
+ "url_link": _url_encode,
163
153
  "menu_items": app.db.get_menu_items_in_lang(locale),
164
154
  "logo_url": app.db.get_logo_url(),
165
155
  "favicon_url": app.db.get_favicon_url(),
@@ -239,11 +229,14 @@ def create_app_from_config(config: Config) -> Engine:
239
229
  login_methods=engine.login_methods, cms_modules=engine.cms_modules
240
230
  )
241
231
 
242
- if config.feature_flags and config.feature_flags.get("FAKE_LOGIN", False):
243
- from platzky.admin.fake_login import get_fake_login_html, setup_fake_login_routes
232
+ # Two-layer defense: is_enabled() gates the feature flag, and
233
+ # DebugBlueprint.register() independently blocks registration
234
+ # unless the app is in debug or testing mode.
235
+ if engine.is_enabled(FakeLogin):
236
+ from platzky.debug.fake_login import create_fake_login_blueprint, get_fake_login_html
244
237
 
245
238
  engine.login_methods.append(get_fake_login_html())
246
- admin_blueprint = setup_fake_login_routes(admin_blueprint)
239
+ engine.register_blueprint(create_fake_login_blueprint())
247
240
 
248
241
  blog_blueprint = blog.create_blog_blueprint(
249
242
  db=engine.db,
@@ -77,16 +77,15 @@ def create_seo_blueprint(
77
77
  """
78
78
  lang = locale_func()
79
79
 
80
- global url
81
80
  host_components = urllib.parse.urlparse(request.host_url)
82
81
  host_base = host_components.scheme + "://" + host_components.netloc
83
82
 
84
83
  # Static routes with static content
85
- static_urls = []
86
- for rule in current_app.url_map.iter_rules():
87
- if rule.methods is not None and "GET" in rule.methods and len(rule.arguments) == 0:
88
- url = {"loc": f"{host_base}{rule!s}"}
89
- static_urls.append(url)
84
+ static_urls = [
85
+ {"loc": f"{host_base}{rule!s}"}
86
+ for rule in current_app.url_map.iter_rules()
87
+ if rule.methods is not None and "GET" in rule.methods and len(rule.arguments) == 0
88
+ ]
90
89
 
91
90
  dynamic_urls = get_blog_entries(host_base, lang, db, config["BLOG_PREFIX"])
92
91
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "platzky"
3
- version = "1.3.1"
3
+ version = "1.4.1"
4
4
  description = "Not only blog engine"
5
5
  authors = []
6
6
  license = "MIT"
@@ -89,6 +89,7 @@ show_missing = true
89
89
  pythonVersion = "3.10"
90
90
  pythonPlatform = "All"
91
91
  typeCheckingMode = "strict"
92
+ exclude = ["docs"] # Sphinx extension has optional deps not in dev group
92
93
  reportMissingImports = true
93
94
  reportMissingTypeStubs = false
94
95
  reportMissingParameterType = false
@@ -1,3 +0,0 @@
1
- from platzky.engine import Engine as Engine
2
- from platzky.platzky import create_app_from_config as create_app_from_config
3
- from platzky.platzky import create_engine as create_engine
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes