platzky 0.1.19__py3-none-any.whl → 0.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.
- platzky/blog/__init__.py +0 -0
- platzky/blog/blog.py +61 -35
- platzky/blog/comment_form.py +8 -4
- platzky/config.py +52 -49
- platzky/db/__init__.py +0 -0
- platzky/db/db.py +107 -0
- platzky/db/db_loader.py +32 -0
- platzky/db/google_json_db.py +33 -10
- platzky/db/graph_ql_db.py +117 -27
- platzky/db/json_db.py +83 -19
- platzky/db/json_file_db.py +25 -9
- platzky/models.py +64 -0
- platzky/platzky.py +122 -62
- platzky/plugin_loader.py +31 -34
- platzky/plugins/redirections/entrypoint.py +41 -19
- platzky/plugins/sendmail/entrypoint.py +31 -10
- platzky/seo/seo.py +34 -24
- platzky/static/blog.css +5 -12
- platzky/templates/base.html +137 -22
- platzky/templates/blog.html +3 -2
- platzky/templates/body_meta.html +4 -21
- platzky/templates/feed.xml +1 -1
- platzky/templates/head_meta.html +5 -15
- platzky/templates/post.html +2 -2
- platzky/www_handler.py +7 -4
- {platzky-0.1.19.dist-info → platzky-0.2.0.dist-info}/METADATA +16 -20
- platzky-0.2.0.dist-info/RECORD +34 -0
- {platzky-0.1.19.dist-info → platzky-0.2.0.dist-info}/WHEEL +1 -1
- platzky/blog/db.py +0 -18
- platzky/blog/post_formatter.py +0 -16
- platzky/db_loader.py +0 -11
- platzky-0.1.19.dist-info/RECORD +0 -32
platzky/blog/__init__.py
ADDED
|
File without changes
|
platzky/blog/blog.py
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
from flask import request, render_template,\
|
|
2
|
-
make_response, Blueprint, Markup
|
|
3
|
-
from platzky.blog import comment_form, post_formatter
|
|
4
1
|
from os.path import dirname
|
|
5
2
|
|
|
3
|
+
from flask import Blueprint, make_response, render_template, request
|
|
4
|
+
from markupsafe import Markup
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
url_prefix = config["BLOG_PREFIX"]
|
|
9
|
-
blog = Blueprint('blog', __name__, url_prefix=url_prefix, template_folder=f'{dirname(__file__)}/../templates')
|
|
6
|
+
from . import comment_form
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
|
|
9
|
+
def create_blog_blueprint(db, blog_prefix: str, locale_func):
|
|
10
|
+
url_prefix = blog_prefix
|
|
11
|
+
blog = Blueprint(
|
|
12
|
+
"blog",
|
|
13
|
+
__name__,
|
|
14
|
+
url_prefix=url_prefix,
|
|
15
|
+
template_folder=f"{dirname(__file__)}/../templates",
|
|
16
|
+
)
|
|
13
17
|
|
|
14
18
|
@blog.app_template_filter()
|
|
15
19
|
def markdown(text):
|
|
@@ -17,50 +21,72 @@ def create_blog_blueprint(db, config, babel):
|
|
|
17
21
|
|
|
18
22
|
@blog.errorhandler(404)
|
|
19
23
|
def page_not_found(e):
|
|
20
|
-
return render_template(
|
|
24
|
+
return render_template("404.html", title="404"), 404
|
|
21
25
|
|
|
22
|
-
@blog.route(
|
|
23
|
-
def
|
|
24
|
-
lang =
|
|
25
|
-
|
|
26
|
+
@blog.route("/", methods=["GET"])
|
|
27
|
+
def all_posts():
|
|
28
|
+
lang = locale_func()
|
|
29
|
+
posts = db.get_all_posts(lang)
|
|
30
|
+
if not posts:
|
|
31
|
+
return page_not_found("no posts")
|
|
32
|
+
posts_sorted = sorted(posts, reverse=True)
|
|
33
|
+
return render_template("blog.html", posts=posts_sorted)
|
|
26
34
|
|
|
27
|
-
@blog.route(
|
|
35
|
+
@blog.route("/feed", methods=["GET"])
|
|
28
36
|
def get_feed():
|
|
29
|
-
lang =
|
|
30
|
-
response = make_response(
|
|
37
|
+
lang = locale_func()
|
|
38
|
+
response = make_response(
|
|
39
|
+
render_template("feed.xml", posts=db.get_all_posts(lang))
|
|
40
|
+
)
|
|
31
41
|
response.headers["Content-Type"] = "application/xml"
|
|
32
42
|
return response
|
|
33
43
|
|
|
34
|
-
@blog.route(
|
|
44
|
+
@blog.route("/<post_slug>", methods=["POST"])
|
|
35
45
|
def post_comment(post_slug):
|
|
36
46
|
comment = request.form.to_dict()
|
|
37
|
-
db.add_comment(
|
|
38
|
-
|
|
47
|
+
db.add_comment(
|
|
48
|
+
post_slug=post_slug,
|
|
49
|
+
author_name=comment["author_name"],
|
|
50
|
+
comment=comment["comment"],
|
|
51
|
+
)
|
|
39
52
|
return get_post(post_slug=post_slug)
|
|
40
53
|
|
|
41
|
-
@blog.route(
|
|
54
|
+
@blog.route("/<post_slug>", methods=["GET"])
|
|
42
55
|
def get_post(post_slug):
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
post = db.get_post(post_slug)
|
|
57
|
+
try:
|
|
58
|
+
post = db.get_post(post_slug)
|
|
59
|
+
return render_template(
|
|
60
|
+
"post.html",
|
|
61
|
+
post=post,
|
|
62
|
+
post_slug=post_slug,
|
|
63
|
+
form=comment_form.CommentForm(),
|
|
64
|
+
comment_sent=request.args.get("comment_sent"),
|
|
65
|
+
)
|
|
66
|
+
except ValueError:
|
|
67
|
+
return page_not_found(f"no post with slug {post_slug}")
|
|
68
|
+
except Exception as e:
|
|
69
|
+
return page_not_found(str(e))
|
|
49
70
|
|
|
50
|
-
@blog.route(
|
|
51
|
-
def get_page(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
@blog.route("/page/<path:page_slug>", methods=["GET"])
|
|
72
|
+
def get_page(
|
|
73
|
+
page_slug,
|
|
74
|
+
): # TODO refactor to share code with get_post since they are very similar
|
|
75
|
+
try:
|
|
76
|
+
page = db.get_page(page_slug)
|
|
77
|
+
if cover_image := page.coverImage:
|
|
78
|
+
cover_image_url = cover_image.url
|
|
55
79
|
else:
|
|
56
80
|
cover_image_url = None
|
|
57
81
|
return render_template("page.html", page=page, cover_image=cover_image_url)
|
|
58
|
-
|
|
59
|
-
return page_not_found("no
|
|
82
|
+
except ValueError:
|
|
83
|
+
return page_not_found("no page with slug {page_slug}")
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return page_not_found(str(e))
|
|
60
86
|
|
|
61
|
-
@blog.route(
|
|
87
|
+
@blog.route("/tag/<path:tag>", methods=["GET"])
|
|
62
88
|
def get_posts_from_tag(tag):
|
|
63
|
-
lang =
|
|
89
|
+
lang = locale_func()
|
|
64
90
|
posts = db.get_posts_by_tag(tag, lang)
|
|
65
91
|
return render_template("blog.html", posts=posts, subtitle=f" - tag: {tag}")
|
|
66
92
|
|
platzky/blog/comment_form.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
from flask_babel import lazy_gettext
|
|
1
2
|
from flask_wtf import FlaskForm
|
|
2
3
|
from wtforms import StringField, SubmitField
|
|
3
4
|
from wtforms.validators import DataRequired
|
|
4
5
|
from wtforms.widgets import TextArea
|
|
5
|
-
from flask_babel import lazy_gettext
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class CommentForm(FlaskForm):
|
|
9
|
-
author_name = StringField(lazy_gettext(
|
|
10
|
-
comment = StringField(
|
|
11
|
-
|
|
9
|
+
author_name = StringField(str(lazy_gettext("Name")), validators=[DataRequired()])
|
|
10
|
+
comment = StringField(
|
|
11
|
+
str(lazy_gettext("Type comment here")),
|
|
12
|
+
validators=[DataRequired()],
|
|
13
|
+
widget=TextArea(),
|
|
14
|
+
)
|
|
15
|
+
submit = SubmitField(str(lazy_gettext("Comment")))
|
platzky/config.py
CHANGED
|
@@ -1,63 +1,66 @@
|
|
|
1
|
-
import
|
|
1
|
+
import sys
|
|
2
|
+
import typing as t
|
|
2
3
|
import yaml
|
|
4
|
+
from pydantic import ConfigDict, BaseModel, Field
|
|
3
5
|
|
|
6
|
+
from .db.db import DBConfig
|
|
7
|
+
from .db.db_loader import get_db_module
|
|
4
8
|
|
|
5
|
-
def is_db_ok(mapping):
|
|
6
|
-
if 'DB' not in mapping:
|
|
7
|
-
raise Exception("DB not set")
|
|
8
|
-
if 'TYPE' not in mapping['DB']:
|
|
9
|
-
raise Exception("DB type is not set")
|
|
10
|
-
if mapping['DB']['TYPE'] not in ['graph_ql', 'json_file', 'google_json']:
|
|
11
|
-
raise Exception("DB type is not supported")
|
|
12
|
-
return True
|
|
13
9
|
|
|
10
|
+
class StrictBaseModel(BaseModel):
|
|
11
|
+
model_config = ConfigDict(frozen=True)
|
|
14
12
|
|
|
15
|
-
class Config():
|
|
16
|
-
def __init__(self, mapping):
|
|
17
|
-
if is_db_ok(mapping):
|
|
18
|
-
self.config = mapping
|
|
19
|
-
else:
|
|
20
|
-
raise Exception("Config is wrong")
|
|
21
13
|
|
|
22
|
-
|
|
23
|
-
|
|
14
|
+
class LanguageConfig(StrictBaseModel):
|
|
15
|
+
name: str = Field(alias="name")
|
|
16
|
+
flag: str = Field(alias="flag")
|
|
17
|
+
domain: t.Optional[str] = Field(default=None, alias="domain")
|
|
24
18
|
|
|
25
|
-
def asdict(self):
|
|
26
|
-
return self.config
|
|
27
19
|
|
|
20
|
+
Languages = dict[str, LanguageConfig]
|
|
21
|
+
LanguagesMapping = t.Mapping[str, t.Mapping[str, str]]
|
|
28
22
|
|
|
29
|
-
def get_config_mapping(base_config):
|
|
30
|
-
default_config = { # TODO move it to platzky
|
|
31
|
-
"USE_WWW": True,
|
|
32
|
-
"SEO_PREFIX": "/",
|
|
33
|
-
"BLOG_PREFIX": "/",
|
|
34
|
-
"LANGUAGES": {},
|
|
35
|
-
"DOMAIN_TO_LANG": {},
|
|
36
|
-
"PLUGINS": []
|
|
37
|
-
}
|
|
38
23
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
config["BABEL_TRANSLATION_DIRECTORIES"] = babel_format_dir
|
|
42
|
-
return config
|
|
24
|
+
def languages_dict(languages: Languages) -> LanguagesMapping:
|
|
25
|
+
return {name: lang.model_dump() for name, lang in languages.items()}
|
|
43
26
|
|
|
44
27
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
28
|
+
class Config(StrictBaseModel):
|
|
29
|
+
app_name: str = Field(alias="APP_NAME")
|
|
30
|
+
secret_key: str = Field(alias="SECRET_KEY")
|
|
31
|
+
db: DBConfig = Field(alias="DB")
|
|
32
|
+
use_www: bool = Field(default=True, alias="USE_WWW")
|
|
33
|
+
seo_prefix: str = Field(default="/", alias="SEO_PREFIX")
|
|
34
|
+
blog_prefix: str = Field(default="/", alias="BLOG_PREFIX")
|
|
35
|
+
languages: Languages = Field(default_factory=dict, alias="LANGUAGES")
|
|
36
|
+
domain_to_lang: dict[str, str] = Field(default_factory=dict, alias="DOMAIN_TO_LANG")
|
|
37
|
+
translation_directories: list[str] = Field(
|
|
38
|
+
default_factory=list,
|
|
39
|
+
alias="TRANSLATION_DIRECTORIES",
|
|
40
|
+
)
|
|
41
|
+
debug: bool = Field(default=False, alias="DEBUG")
|
|
42
|
+
testing: bool = Field(default=False, alias="TESTING")
|
|
54
43
|
|
|
55
|
-
|
|
56
|
-
|
|
44
|
+
@classmethod
|
|
45
|
+
def model_validate(
|
|
46
|
+
cls,
|
|
47
|
+
obj: t.Any,
|
|
48
|
+
*,
|
|
49
|
+
strict: bool | None = None,
|
|
50
|
+
from_attributes: bool | None = None,
|
|
51
|
+
context: dict[str, t.Any] | None = None,
|
|
52
|
+
) -> "Config":
|
|
53
|
+
db_cfg_type = get_db_module(obj["DB"]["TYPE"]).db_config_type()
|
|
54
|
+
obj["DB"] = db_cfg_type.model_validate(obj["DB"])
|
|
55
|
+
return super().model_validate(
|
|
56
|
+
obj, strict=strict, from_attributes=from_attributes, context=context
|
|
57
|
+
)
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
@classmethod
|
|
60
|
+
def parse_yaml(cls, path: str) -> "Config":
|
|
61
|
+
try:
|
|
62
|
+
with open(path, "r") as f:
|
|
63
|
+
return cls.model_validate(yaml.safe_load(f))
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
print(f"Config file not found: {path}", file=sys.stderr)
|
|
66
|
+
raise SystemExit(1)
|
platzky/db/__init__.py
ADDED
|
File without changes
|
platzky/db/db.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from functools import partial
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from abc import abstractmethod, ABC
|
|
7
|
+
from ..models import MenuItem, Post, Page, Color
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DB(ABC):
|
|
11
|
+
db_name: str = "DB"
|
|
12
|
+
module_name: str = "db"
|
|
13
|
+
config_type: type
|
|
14
|
+
|
|
15
|
+
def __init_subclass__(cls, *args, **kw):
|
|
16
|
+
"""Check that all methods defined in the subclass exist in the superclasses.
|
|
17
|
+
This is to prevent subclasses from adding new methods to the DB object.
|
|
18
|
+
"""
|
|
19
|
+
super().__init_subclass__(*args, **kw)
|
|
20
|
+
for name in cls.__dict__:
|
|
21
|
+
if name.startswith("_"):
|
|
22
|
+
continue
|
|
23
|
+
for superclass in cls.__mro__[1:]:
|
|
24
|
+
if name in dir(superclass):
|
|
25
|
+
break
|
|
26
|
+
else:
|
|
27
|
+
raise TypeError(
|
|
28
|
+
f"Method {name} defined in {cls.__name__} does not exist in superclasses"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def extend(self, function_name: str, function: Callable) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Add a function to the DB object. The function must take the DB object as first parameter.
|
|
34
|
+
|
|
35
|
+
Parameters:
|
|
36
|
+
function_name (str): The name of the function to add.
|
|
37
|
+
function (Callable): The function to add to the DB object.
|
|
38
|
+
"""
|
|
39
|
+
if not callable(function):
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"The provided func for '{function_name}' is not callable."
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
bound_function = partial(function, self)
|
|
45
|
+
setattr(self, function_name, bound_function)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
raise ValueError(f"Failed to extend DB with function {function_name}: {e}")
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def get_all_posts(self, lang) -> list[Post]:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def get_menu_items(self) -> list[MenuItem]:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def get_post(self, slug) -> Post:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def get_page(self, slug) -> Page:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def get_posts_by_tag(self, tag, lang) -> Any:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def add_comment(self, author_name, comment, post_slug) -> None:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def get_logo_url(self) -> str:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def get_primary_color(self) -> Color:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def get_secondary_color(self) -> Color:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def get_plugins_data(self) -> list:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def get_font(self) -> str:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def get_all_providers(self): # TODO providers are not part of the DB
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def get_all_questions(self): # TODO questions are not part of the DB
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def get_site_content(self) -> str:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DBConfig(BaseModel):
|
|
107
|
+
type: str = Field(alias="TYPE")
|
platzky/db/db_loader.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from os.path import abspath, dirname
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_db(db_config):
|
|
8
|
+
db_name = db_config.type
|
|
9
|
+
db = get_db_module(db_name)
|
|
10
|
+
return db.db_from_config(db_config)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_db_module(db_type):
|
|
14
|
+
"""
|
|
15
|
+
Load db module from db_type
|
|
16
|
+
This function is used to load db module dynamically as it is specified in config file.
|
|
17
|
+
:param db_type: name of db module
|
|
18
|
+
:return: db module
|
|
19
|
+
"""
|
|
20
|
+
db_dir = dirname(abspath(__file__))
|
|
21
|
+
parent_module_name = ".".join(__name__.split(".")[:-1])
|
|
22
|
+
module_name = f"{parent_module_name}.{db_type}_db"
|
|
23
|
+
spec = importlib.util.spec_from_file_location(
|
|
24
|
+
module_name, os.path.join(db_dir, f"{db_type}_db.py")
|
|
25
|
+
)
|
|
26
|
+
assert spec is not None
|
|
27
|
+
db = importlib.util.module_from_spec(spec)
|
|
28
|
+
sys.modules[f"{db_type}_db"] = db
|
|
29
|
+
assert spec.loader is not None
|
|
30
|
+
spec.loader.exec_module(db)
|
|
31
|
+
|
|
32
|
+
return db
|
platzky/db/google_json_db.py
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
import json
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from google.cloud.storage import Client
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from .db import DBConfig
|
|
3
7
|
from .json_db import Json
|
|
4
8
|
|
|
5
9
|
|
|
6
|
-
def
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
def db_config_type():
|
|
11
|
+
return GoogleJsonDbConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GoogleJsonDbConfig(DBConfig):
|
|
15
|
+
bucket_name: str = Field(alias="BUCKET_NAME")
|
|
16
|
+
source_blob_name: str = Field(alias="SOURCE_BLOB_NAME")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def db_from_config(config: GoogleJsonDbConfig):
|
|
20
|
+
return GoogleJsonDb(config.bucket_name, config.source_blob_name)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_db(config):
|
|
24
|
+
google_json_db_config = GoogleJsonDbConfig.model_validate(config)
|
|
25
|
+
return GoogleJsonDb(
|
|
26
|
+
google_json_db_config.bucket_name, google_json_db_config.source_blob_name
|
|
27
|
+
)
|
|
11
28
|
|
|
12
29
|
|
|
13
30
|
def get_blob(bucket_name, source_blob_name):
|
|
14
|
-
storage_client =
|
|
31
|
+
storage_client = Client()
|
|
15
32
|
bucket = storage_client.bucket(bucket_name)
|
|
16
33
|
return bucket.blob(source_blob_name)
|
|
17
34
|
|
|
@@ -23,11 +40,17 @@ def get_data(blob):
|
|
|
23
40
|
|
|
24
41
|
class GoogleJsonDb(Json):
|
|
25
42
|
def __init__(self, bucket_name, source_blob_name):
|
|
26
|
-
self.
|
|
43
|
+
self.bucket_name = bucket_name
|
|
44
|
+
self.source_blob_name = source_blob_name
|
|
45
|
+
|
|
46
|
+
self.blob = get_blob(self.bucket_name, self.source_blob_name)
|
|
27
47
|
data = get_data(self.blob)
|
|
28
48
|
super().__init__(data)
|
|
29
49
|
|
|
30
|
-
|
|
50
|
+
self.module_name = "google_json_db"
|
|
51
|
+
self.db_name = "GoogleJsonDb"
|
|
52
|
+
|
|
53
|
+
def __save_entry(self, entry):
|
|
31
54
|
data = get_data(self.blob)
|
|
32
55
|
data["data"].append(entry)
|
|
33
|
-
self.blob.upload_from_string(json.dumps(data), content_type=
|
|
56
|
+
self.blob.upload_from_string(json.dumps(data), content_type="application/json")
|