pdfrender 0.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.
- pdf_render/__init__.py +25 -0
- pdf_render/data/__init__.py +5 -0
- pdf_render/data/accessor.py +161 -0
- pdf_render/data/importer.py +209 -0
- pdf_render/data/model_field_typing.py +159 -0
- pdf_render/import_functions.py +43 -0
- pdf_render/md/__init__.py +3 -0
- pdf_render/md/extensions/__init__.py +13 -0
- pdf_render/md/extensions/columns.py +47 -0
- pdf_render/md/extensions/flex_wrap.py +52 -0
- pdf_render/md/extensions/muted_text.py +42 -0
- pdf_render/md/extensions/nerdfont_glyph.py +37 -0
- pdf_render/md/extensions/page_break.py +39 -0
- pdf_render/md/rendering.py +29 -0
- pdf_render/pdf/__init__.py +5 -0
- pdf_render/pdf/document_template.py +45 -0
- pdf_render/pdf/metadata.py +38 -0
- pdf_render/pdf/options.py +64 -0
- pdf_render/pdf/rendering.py +40 -0
- pdf_render/templates/__init__.py +4 -0
- pdf_render/templates/environment.py +158 -0
- pdf_render/templates/filters/__init__.py +9 -0
- pdf_render/templates/filters/format_filters.py +34 -0
- pdf_render/templates/filters/getter_filters.py +45 -0
- pdf_render/templates/rendering.py +75 -0
- pdf_render/templates/tests/__init__.py +0 -0
- pdf_render/user_input/__init__.py +3 -0
- pdf_render/user_input/undefined_variable_form.py +321 -0
- pdfrender-0.1.0.dist-info/METADATA +96 -0
- pdfrender-0.1.0.dist-info/RECORD +34 -0
- pdfrender-0.1.0.dist-info/WHEEL +5 -0
- pdfrender-0.1.0.dist-info/entry_points.txt +2 -0
- pdfrender-0.1.0.dist-info/licenses/LICENSE +21 -0
- pdfrender-0.1.0.dist-info/top_level.txt +1 -0
pdf_render/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from .data import DataImporter, DBAccessor
|
|
4
|
+
from .md import create_markdown_renderer
|
|
5
|
+
from .pdf import PDFMetadata, PDFOptions, render_pdf
|
|
6
|
+
from .templates import TypedVariableEnvironment, new_template_environment
|
|
7
|
+
from .user_input import prompt_for_undefined_variables
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("pdfrender")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
__version__ = "0.0.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"DataImporter",
|
|
17
|
+
"DBAccessor",
|
|
18
|
+
"create_markdown_renderer",
|
|
19
|
+
"PDFOptions",
|
|
20
|
+
"PDFMetadata",
|
|
21
|
+
"render_pdf",
|
|
22
|
+
"new_template_environment",
|
|
23
|
+
"TypedVariableEnvironment",
|
|
24
|
+
"prompt_for_undefined_variables",
|
|
25
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from typing import Any, Mapping, Type
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Engine, select
|
|
4
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
5
|
+
from sqlalchemy.orm import DeclarativeBase, Session
|
|
6
|
+
|
|
7
|
+
from pdf_render.data.model_field_typing import get_model_base_model_list, get_primary_key_reference
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DBAccessor(Mapping):
|
|
11
|
+
"""Read-only, dict-like access to a collection of SQLAlchemy models.
|
|
12
|
+
|
|
13
|
+
Can be used in place of a plain `dict` as `jinja2.Environment`'s
|
|
14
|
+
`globals` attribute. If an accessed key is not a table name known to
|
|
15
|
+
`base_model`, it falls back to a normal dictionary backed by `self._data`
|
|
16
|
+
(seeded from `data_key_values`). Accessing a table name always returns
|
|
17
|
+
every row for that table as a list of model instances.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
base_model: The parent model every table's model is a subclass of -
|
|
21
|
+
for sqlmodel, `SQLModel`; for sqlalchemy, a subclass of
|
|
22
|
+
`DeclarativeBase`. When using `SQLModel`, every model is read
|
|
23
|
+
from SQLModel's single global registry, since `SQLModel.registry`
|
|
24
|
+
isn't otherwise accessible - passing a custom `registry` to a
|
|
25
|
+
`SQLModel` subclass isn't supported here.
|
|
26
|
+
engine: The return value of `sqlalchemy.create_engine()`
|
|
27
|
+
(`sqlmodel.create_engine()` returns the same type).
|
|
28
|
+
sqlalchemy_session: An already-opened `sqlalchemy.orm.Session`. When
|
|
29
|
+
omitted, a new session is opened on `__enter__` and closed on
|
|
30
|
+
`__exit__`.
|
|
31
|
+
**data_key_values: Initial non-table key/value pairs, accessible the
|
|
32
|
+
same way as table names.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
TypeError: If `engine` is not a `sqlalchemy.Engine`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_engine: Engine = None
|
|
39
|
+
_session: Session = None
|
|
40
|
+
_data: dict[str, DeclarativeBase | None | Any]
|
|
41
|
+
_models: dict[str, Type[DeclarativeBase]]
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
base_model: Type[DeclarativeBase],
|
|
46
|
+
engine: Engine,
|
|
47
|
+
sqlalchemy_session: Session = None,
|
|
48
|
+
**data_key_values,
|
|
49
|
+
):
|
|
50
|
+
if sqlalchemy_session is not None:
|
|
51
|
+
self._session = sqlalchemy_session
|
|
52
|
+
|
|
53
|
+
self._engine = engine
|
|
54
|
+
models = get_model_base_model_list(base_model)
|
|
55
|
+
|
|
56
|
+
if not isinstance(engine, Engine):
|
|
57
|
+
raise TypeError(f"Parameter <engine>'s type should be a subclass of sqlalchemy.Engine - got {engine}")
|
|
58
|
+
|
|
59
|
+
self._models = {model.__name__: model for model in models}
|
|
60
|
+
placeholder_keys = {model: None for model in self._models}
|
|
61
|
+
self._data = {
|
|
62
|
+
**placeholder_keys,
|
|
63
|
+
**data_key_values,
|
|
64
|
+
"get_by_pk": self.get_by_pk,
|
|
65
|
+
"filter_where": self.filter_where,
|
|
66
|
+
"filter_where_eval": self.filter_where_eval,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def _get_from_db(self, model: Type[DeclarativeBase]):
|
|
70
|
+
return self._session.scalars(select(model)).all()
|
|
71
|
+
|
|
72
|
+
def get_by_pk(self, model_name: str, **key_value_kw_params):
|
|
73
|
+
if model_name not in self._models:
|
|
74
|
+
raise KeyError(f"No table with name {model_name}")
|
|
75
|
+
model = self._models[model_name]
|
|
76
|
+
primary_key_reference = get_primary_key_reference(model, key_value_kw_params)
|
|
77
|
+
return self._session.get(model, primary_key_reference)
|
|
78
|
+
|
|
79
|
+
def filter_where(self, model_name: str, **filter_kw_params):
|
|
80
|
+
if model_name not in self._models:
|
|
81
|
+
raise KeyError(f"No table with name {model_name}")
|
|
82
|
+
model = self._models[model_name]
|
|
83
|
+
|
|
84
|
+
select_statement = select(model).where(
|
|
85
|
+
*list(getattr(model, attr) == value for attr, value in filter_kw_params.items())
|
|
86
|
+
)
|
|
87
|
+
return self._session.scalars(select_statement).all()
|
|
88
|
+
|
|
89
|
+
def filter_where_eval(self, model_name: str, eval_statement: str):
|
|
90
|
+
model = self._models.get(model_name)
|
|
91
|
+
if len(model_name.split(".")) > 1:
|
|
92
|
+
try:
|
|
93
|
+
model = eval(model_name, None, self._models)
|
|
94
|
+
except (SyntaxError, NameError, AttributeError, TypeError) as e:
|
|
95
|
+
raise ValueError(f"Could not evaluate model_name {model_name!r}: {e}") from e
|
|
96
|
+
if not model:
|
|
97
|
+
raise KeyError(f"No table with name {model_name}")
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
clauses = [eval(statement, None, self._models) for statement in eval_statement.split(",")]
|
|
101
|
+
except (SyntaxError, NameError, AttributeError, TypeError) as e:
|
|
102
|
+
raise ValueError(f"Could not evaluate eval_statement {eval_statement!r}: {e}") from e
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
select_statement = select(model).where(*clauses)
|
|
106
|
+
return self._session.scalars(select_statement).all()
|
|
107
|
+
except SQLAlchemyError as e:
|
|
108
|
+
raise ValueError(f"Could not build/execute query for eval_statement {eval_statement!r}: {e}") from e
|
|
109
|
+
|
|
110
|
+
def __getitem__(self, key):
|
|
111
|
+
"""
|
|
112
|
+
:param key: The table name of the model or the key from init's data_key_values parameter
|
|
113
|
+
:return: Either the corresponding table name instances or the data from init's data_key_values parameter
|
|
114
|
+
"""
|
|
115
|
+
model = self._models.get(key)
|
|
116
|
+
|
|
117
|
+
if model is None:
|
|
118
|
+
try:
|
|
119
|
+
return self._data.get(key)
|
|
120
|
+
except KeyError as e:
|
|
121
|
+
raise KeyError(f"{self} has no field {key}") from e
|
|
122
|
+
|
|
123
|
+
return self._get_from_db(model)
|
|
124
|
+
|
|
125
|
+
def __setitem__(self, key, item):
|
|
126
|
+
if key not in self._models:
|
|
127
|
+
self._data[key] = item
|
|
128
|
+
|
|
129
|
+
def __delitem__(self, key):
|
|
130
|
+
if key not in self._models:
|
|
131
|
+
del self._data[key]
|
|
132
|
+
|
|
133
|
+
def __iter__(self):
|
|
134
|
+
return iter(self._data)
|
|
135
|
+
|
|
136
|
+
def __contains__(self, key):
|
|
137
|
+
return key in self._data
|
|
138
|
+
|
|
139
|
+
def __enter__(self):
|
|
140
|
+
if self._session is None:
|
|
141
|
+
self._session = Session(self._engine)
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, *_):
|
|
145
|
+
self._session.close()
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def __len__(self):
|
|
149
|
+
return len(self._data)
|
|
150
|
+
|
|
151
|
+
def get(self, key, default=None):
|
|
152
|
+
if key in self:
|
|
153
|
+
return self[key]
|
|
154
|
+
|
|
155
|
+
return default
|
|
156
|
+
|
|
157
|
+
def update(self, _dict=None, /, **kwargs):
|
|
158
|
+
if _dict is not None:
|
|
159
|
+
self._data.update(_dict)
|
|
160
|
+
if kwargs:
|
|
161
|
+
self._data.update(kwargs)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from datetime import date, datetime
|
|
2
|
+
from inspect import isclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Engine
|
|
7
|
+
from sqlalchemy.exc import InvalidRequestError
|
|
8
|
+
from sqlalchemy.orm import DeclarativeBase, Session
|
|
9
|
+
from sqlmodel import SQLModel
|
|
10
|
+
from yaml import safe_load
|
|
11
|
+
|
|
12
|
+
from pdf_render.data.model_field_typing import (
|
|
13
|
+
SCALAR_TYPES,
|
|
14
|
+
ModelType,
|
|
15
|
+
ScalarType,
|
|
16
|
+
get_date_from_date_str,
|
|
17
|
+
get_datetime_from_datetime_str,
|
|
18
|
+
get_first_type_from_union,
|
|
19
|
+
get_model_base_model_list,
|
|
20
|
+
get_model_type_annotations,
|
|
21
|
+
get_primary_key_reference,
|
|
22
|
+
is_list_type,
|
|
23
|
+
is_scalar_type,
|
|
24
|
+
is_union_type,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DataImporter:
|
|
29
|
+
"""Imports YAML data into a SQLAlchemy/SQLModel-backed database.
|
|
30
|
+
|
|
31
|
+
Reads a YAML sequence of records for a given model, coerces each field to
|
|
32
|
+
the model's declared type (including nested relationships and lists of
|
|
33
|
+
related models), and upserts the resulting rows by primary key so that
|
|
34
|
+
re-importing the same data updates existing rows instead of duplicating
|
|
35
|
+
them.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
base_model: The parent model every importable model is a subclass
|
|
39
|
+
of - for sqlmodel, `SQLModel`; for sqlalchemy, a subclass of
|
|
40
|
+
`DeclarativeBase`. When using `SQLModel`, every model is read
|
|
41
|
+
from SQLModel's single global registry, since `SQLModel.registry`
|
|
42
|
+
isn't otherwise accessible - passing a custom `registry` to a
|
|
43
|
+
`SQLModel` subclass isn't supported here.
|
|
44
|
+
engine: The return value of `sqlalchemy.create_engine()`
|
|
45
|
+
(`sqlmodel.create_engine()` returns the same type).
|
|
46
|
+
sqlalchemy_session: An already-opened `sqlalchemy.orm.Session`. When
|
|
47
|
+
omitted, a new session is opened on `__enter__` and closed on
|
|
48
|
+
`__exit__`.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
TypeError: If `engine` is not a `sqlalchemy.Engine`.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_engine: Engine = None
|
|
55
|
+
_session: Session = None
|
|
56
|
+
_base_model: ModelType
|
|
57
|
+
_model_fields: dict[str, Any]
|
|
58
|
+
_all_models: dict[str, ModelType]
|
|
59
|
+
_current_file: Path
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
base_model: ModelType,
|
|
64
|
+
engine: Engine,
|
|
65
|
+
sqlalchemy_session: Session = None,
|
|
66
|
+
):
|
|
67
|
+
"""
|
|
68
|
+
:param base_model:
|
|
69
|
+
The parent model that each created model is a subclass of,
|
|
70
|
+
for sqlmodel: SQLModel, for sqlalchemy: a subclass of DeclarativeBase
|
|
71
|
+
:param engine:
|
|
72
|
+
The return value of sqlalchemy.create_engine() (sqlmodel uses the same function)
|
|
73
|
+
:param (optional) sqlalchemy_session:
|
|
74
|
+
[Optional] An instantiated and opened sqlalchemy.Session object, typically via the context manager protocol
|
|
75
|
+
- `with Session(engine) as session:`
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
if sqlalchemy_session is not None:
|
|
79
|
+
self._session = sqlalchemy_session
|
|
80
|
+
|
|
81
|
+
self._engine = engine
|
|
82
|
+
|
|
83
|
+
if not isinstance(engine, Engine):
|
|
84
|
+
raise TypeError(f"Parameter <engine>'s type should be a subclass of sqlalchemy.Engine - got {engine}")
|
|
85
|
+
|
|
86
|
+
self._base_model = base_model
|
|
87
|
+
self._all_models = {m.__name__: m for m in get_model_base_model_list(base_model)}
|
|
88
|
+
self._model_fields = {m.__name__: get_model_type_annotations(m) for m in self._all_models.values()}
|
|
89
|
+
|
|
90
|
+
def load_data_from_file(self, yaml_text: str, model: ModelType):
|
|
91
|
+
data = safe_load(yaml_text)
|
|
92
|
+
if isinstance(data, Mapping):
|
|
93
|
+
raise TypeError("Top-level YAML object should be a Sequence - got Mapping")
|
|
94
|
+
for instance in data:
|
|
95
|
+
self._session.add_all([self._upsert(model, self._coerce_instance_data_types(model, instance))])
|
|
96
|
+
self._session.commit()
|
|
97
|
+
|
|
98
|
+
def _coerce_sequence(self, model: ModelType, field_key: str, field_value: Any, coerced_data: dict):
|
|
99
|
+
if not isinstance(field_value, Sequence):
|
|
100
|
+
raise TypeError(
|
|
101
|
+
f"Invalid type for {model.__name__}.{field_key}, expected Sequence - got '{type(field_value)}'"
|
|
102
|
+
)
|
|
103
|
+
if not field_value:
|
|
104
|
+
coerced_data[field_key] = []
|
|
105
|
+
return coerced_data
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
type_annotation = self._model_fields[model.__name__].get(field_key).__args__[0]
|
|
109
|
+
if not isinstance(type_annotation, str) and hasattr(type_annotation, "__forward_arg__"):
|
|
110
|
+
type_annotation = type_annotation.__forward_arg__
|
|
111
|
+
model_name = type_annotation
|
|
112
|
+
|
|
113
|
+
except AttributeError as e:
|
|
114
|
+
raise AttributeError(
|
|
115
|
+
f"Error processing type for {model.__name__}.{field_key} with annotations "
|
|
116
|
+
f"'{self._model_fields[model.__name__].get(field_key).__args__[0]}' "
|
|
117
|
+
f"(Type {type(self._model_fields[model.__name__].get(field_key).__args__[0])})"
|
|
118
|
+
f": {e}"
|
|
119
|
+
) from e
|
|
120
|
+
|
|
121
|
+
if isclass(model_name) and issubclass(model_name, DeclarativeBase):
|
|
122
|
+
model_name = model_name.__tablename__
|
|
123
|
+
|
|
124
|
+
model_type = self._all_models.get(model_name)
|
|
125
|
+
if not model_type:
|
|
126
|
+
model_type = model_name
|
|
127
|
+
|
|
128
|
+
coerced_data[field_key] = []
|
|
129
|
+
|
|
130
|
+
for sequence_index, instance in enumerate(field_value):
|
|
131
|
+
coerced_data[field_key].append(self._upsert(model_type, instance))
|
|
132
|
+
|
|
133
|
+
return coerced_data
|
|
134
|
+
|
|
135
|
+
def _coerce_model(
|
|
136
|
+
self, model: ModelType, field_key: str, field_value: Any, field_type: ModelType, coerced_data: dict
|
|
137
|
+
):
|
|
138
|
+
if not isinstance(field_value, Mapping):
|
|
139
|
+
raise TypeError(
|
|
140
|
+
f"Invalid type for {model.__name__}.{field_key}, expected Mapping - got '{type(field_value)}'"
|
|
141
|
+
)
|
|
142
|
+
coerced_data[field_key] = self._upsert(field_type, field_value)
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _coerce_scalar(model: ModelType, field_key: str, field_value: Any, field_type: ScalarType, coerced_data: dict):
|
|
146
|
+
if not is_scalar_type(field_value) and field_value is not None:
|
|
147
|
+
scalar_types_str = "[" + ", ".join(str(_type) for _type in SCALAR_TYPES) + "]"
|
|
148
|
+
raise TypeError(
|
|
149
|
+
f"Invalid type for {model.__name__}.{field_key}, "
|
|
150
|
+
f"expecting '{field_type}' '{scalar_types_str}' - got '{type(field_value)}'"
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
coerced_data[field_key] = field_type(field_value)
|
|
154
|
+
except TypeError as e:
|
|
155
|
+
raise TypeError(f"{e.args}\n\n\n{field_type} is not callable") from e
|
|
156
|
+
|
|
157
|
+
def _coerce_instance_data_types(self, model: ModelType, instance_data: dict[str, Any]):
|
|
158
|
+
coerced_data = {}
|
|
159
|
+
for field_key in instance_data:
|
|
160
|
+
field_type = self._model_fields[model.__name__].get(field_key, None)
|
|
161
|
+
|
|
162
|
+
if is_union_type(field_type):
|
|
163
|
+
field_type = get_first_type_from_union(field_type)
|
|
164
|
+
|
|
165
|
+
field_value = instance_data[field_key]
|
|
166
|
+
if field_type is None:
|
|
167
|
+
raise KeyError(f"Invalid field for model '{model.__name__}': Has no field '{field_key}'")
|
|
168
|
+
|
|
169
|
+
if is_list_type(field_type):
|
|
170
|
+
coerced_data = self._coerce_sequence(model, field_key, field_value, coerced_data)
|
|
171
|
+
|
|
172
|
+
elif isclass(field_type) and issubclass(field_type, (DeclarativeBase, SQLModel)):
|
|
173
|
+
self._coerce_model(model, field_key, field_value, field_type, coerced_data)
|
|
174
|
+
|
|
175
|
+
elif field_type is date:
|
|
176
|
+
coerced_data[field_key] = get_date_from_date_str(field_value)
|
|
177
|
+
|
|
178
|
+
elif field_type is datetime:
|
|
179
|
+
coerced_data[field_key] = get_datetime_from_datetime_str(field_value)
|
|
180
|
+
|
|
181
|
+
else:
|
|
182
|
+
self._coerce_scalar(model, field_key, field_value, field_type, coerced_data)
|
|
183
|
+
|
|
184
|
+
return coerced_data
|
|
185
|
+
|
|
186
|
+
def _upsert(self, model: ModelType, reference_value: Any):
|
|
187
|
+
primary_key_reference = get_primary_key_reference(model, reference_value)
|
|
188
|
+
init_data = reference_value if isinstance(reference_value, (Mapping, dict)) else primary_key_reference
|
|
189
|
+
try:
|
|
190
|
+
instance = self._session.get(model, primary_key_reference)
|
|
191
|
+
except InvalidRequestError as e:
|
|
192
|
+
raise InvalidRequestError(f"{e}: got {primary_key_reference}")
|
|
193
|
+
|
|
194
|
+
if not instance:
|
|
195
|
+
return model(**init_data)
|
|
196
|
+
|
|
197
|
+
for key, value in init_data.items():
|
|
198
|
+
setattr(instance, key, value)
|
|
199
|
+
|
|
200
|
+
return instance
|
|
201
|
+
|
|
202
|
+
def __enter__(self):
|
|
203
|
+
if self._session is None:
|
|
204
|
+
self._session = Session(self._engine)
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
def __exit__(self, *_):
|
|
208
|
+
self._session.close()
|
|
209
|
+
return False
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import typing
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from inspect import isclass
|
|
7
|
+
from pprint import pformat
|
|
8
|
+
from types import UnionType
|
|
9
|
+
|
|
10
|
+
# Only importing the whole modules so ModelType's docstring shows which package the classes come from
|
|
11
|
+
import sqlalchemy.orm
|
|
12
|
+
import sqlmodel
|
|
13
|
+
from sqlmodel.main import default_registry
|
|
14
|
+
|
|
15
|
+
SCALAR_TYPES = {str, int, bytes, float, bool}
|
|
16
|
+
ScalarType: typing.TypeAlias = typing.Type[str | int | bytes | float | bool]
|
|
17
|
+
|
|
18
|
+
VALID_DATETIME_FORMATS: tuple[str, ...] = (
|
|
19
|
+
# Ordered deliberately: a plain tuple (not a set) so match priority for an
|
|
20
|
+
# ambiguous input (e.g. a trailing "Z") is deterministic across runs.
|
|
21
|
+
"%Y-%m-%dT%H:%M:%S.%fZ",
|
|
22
|
+
"%Y-%m-%dT%H:%M:%SZ",
|
|
23
|
+
"%Y-%m-%dT%H:%M:%S.%f",
|
|
24
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
25
|
+
"%Y-%m-%dT%H:%M:%S.%f%z",
|
|
26
|
+
"%Y-%m-%dT%H:%M:%S%z",
|
|
27
|
+
"%Y-%m-%d",
|
|
28
|
+
"%H:%M:%S.%f",
|
|
29
|
+
"%H:%M:%S",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
ModelType: typing.TypeAlias = typing.Type[sqlmodel.SQLModel | sqlalchemy.orm.DeclarativeBase]
|
|
33
|
+
"""A model class - either an SQLModel or SQLAlchemy DeclarativeBase subclass."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_model_base_model_list(base_model: ModelType):
|
|
37
|
+
if issubclass(base_model, sqlmodel.SQLModel):
|
|
38
|
+
# SQLModel.registry is inaccessible because of some metaclassing sorcery, so this
|
|
39
|
+
# always reads from SQLModel's single global registry instead. Documented as a
|
|
40
|
+
# restriction on DataImporter/DBAccessor's base_model - see their docstrings.
|
|
41
|
+
models = [i.class_ for i in default_registry.mappers]
|
|
42
|
+
elif isclass(base_model) and issubclass(base_model, (sqlalchemy.orm.DeclarativeBase, sqlmodel.SQLModel)):
|
|
43
|
+
models = [i.class_ for i in base_model.registry.mappers]
|
|
44
|
+
else:
|
|
45
|
+
raise TypeError(
|
|
46
|
+
f"Parameter <base_model>'s type should be a subclass of sqlalchemy.DeclarativeBase - got {base_model}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return models
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def derive_type_from_mapped_column(mapped: sqlalchemy.orm.Mapped, module_name: str):
|
|
53
|
+
first_arg = mapped.__args__[0]
|
|
54
|
+
|
|
55
|
+
if not isinstance(first_arg, typing.ForwardRef):
|
|
56
|
+
return first_arg
|
|
57
|
+
|
|
58
|
+
module = importlib.import_module(module_name)
|
|
59
|
+
return getattr(module, first_arg.__forward_arg__, None)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def derive_type_from_annotated_type(annotated_type: typing.Any, module_name: str):
|
|
63
|
+
if not isinstance(annotated_type, typing._GenericAlias):
|
|
64
|
+
return annotated_type
|
|
65
|
+
|
|
66
|
+
return derive_type_from_mapped_column(annotated_type, module_name)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_model_type_annotations(model: ModelType):
|
|
70
|
+
if issubclass(model, sqlmodel.SQLModel):
|
|
71
|
+
annotations = {
|
|
72
|
+
name: derive_type_from_annotated_type(annotation, model.__module__)
|
|
73
|
+
for name, annotation in model.__annotations__.items()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
elif issubclass(model, sqlalchemy.orm.DeclarativeBase):
|
|
77
|
+
annotations = {
|
|
78
|
+
name: derive_type_from_mapped_column(annotation, model.__module__)
|
|
79
|
+
for name, annotation in model.__annotations__.items()
|
|
80
|
+
}
|
|
81
|
+
else:
|
|
82
|
+
raise TypeError(f"Parameter <model>'s type should be a subclass of sqlalchemy.DeclarativeBase - got {model}")
|
|
83
|
+
return annotations
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_scalar_type(value: typing.Any):
|
|
87
|
+
return any(isinstance(value, _type) for _type in SCALAR_TYPES)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_union_type(_type: typing.Any) -> bool:
|
|
91
|
+
origin = typing.get_origin(_type)
|
|
92
|
+
return origin is typing.Union or origin is UnionType
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_list_type(_type: typing.Any) -> bool:
|
|
96
|
+
origin = typing.get_origin(_type)
|
|
97
|
+
return origin is list
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_forward_ref(_type: typing.Any) -> bool:
|
|
101
|
+
origin = typing.get_origin(_type)
|
|
102
|
+
return origin is typing.ForwardRef
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_first_type_from_union(_type: typing.Union | UnionType):
|
|
106
|
+
for arg in _type.__args__:
|
|
107
|
+
if arg is not None:
|
|
108
|
+
return arg
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_primary_key_reference(model: ModelType, reference_value: typing.Any):
|
|
113
|
+
primary_key_field = model.__table__.primary_key
|
|
114
|
+
|
|
115
|
+
field_types = get_model_type_annotations(model)
|
|
116
|
+
primary_keys = [key.name for key in primary_key_field]
|
|
117
|
+
primary_key_types = {primary_key: field_types[primary_key] for primary_key in primary_keys}
|
|
118
|
+
|
|
119
|
+
primary_key_types_str = "[" + ", ".join(f"{k}: {v}" for k, v in primary_key_types.items()) + "]"
|
|
120
|
+
key_type = "single" if len(primary_key_types) == 1 else " composite"
|
|
121
|
+
if not isinstance(reference_value, (typing.Mapping, dict)):
|
|
122
|
+
raise TypeError(
|
|
123
|
+
f"Invalid primary key reference for {model}."
|
|
124
|
+
f"{model.__name__} has a {key_type} primary key: {primary_key_types_str} "
|
|
125
|
+
f" - got {pformat(reference_value)}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if not all(key in reference_value for key in primary_key_types):
|
|
129
|
+
missing_keys = [key for key in primary_key_types if key not in reference_value]
|
|
130
|
+
raise KeyError(
|
|
131
|
+
f"Invalid model reference for {model}."
|
|
132
|
+
f"{model.__name__} has a {key_type} primary key: {primary_key_types_str} "
|
|
133
|
+
f"Missing key(s)='{', '.join(missing_keys)}'"
|
|
134
|
+
f" - got {pformat(reference_value)}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return {k: reference_value[k] for k in primary_key_types}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_date_from_date_str(maybe_date_str: str):
|
|
141
|
+
if not isinstance(maybe_date_str, str):
|
|
142
|
+
return maybe_date_str
|
|
143
|
+
try:
|
|
144
|
+
return datetime.strptime(maybe_date_str, "%Y-%m-%d").date()
|
|
145
|
+
except ValueError:
|
|
146
|
+
raise ValueError(f"Could not parse '{maybe_date_str}' as a date")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_datetime_from_datetime_str(maybe_datetime_str: str):
|
|
150
|
+
if isinstance(maybe_datetime_str, date) or isinstance(maybe_datetime_str, datetime):
|
|
151
|
+
return maybe_datetime_str
|
|
152
|
+
|
|
153
|
+
for fmt in VALID_DATETIME_FORMATS:
|
|
154
|
+
try:
|
|
155
|
+
return datetime.strptime(maybe_datetime_str, fmt)
|
|
156
|
+
except ValueError:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
raise ValueError(f"Could not parse '{maybe_datetime_str}' as a datetime")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import pkgutil
|
|
3
|
+
from inspect import isfunction
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def import_submodules(module_name: str, recursive: bool = True, has_attr_filter: str = None):
|
|
8
|
+
if isinstance(module_name, str):
|
|
9
|
+
module_name = importlib.import_module(module_name)
|
|
10
|
+
|
|
11
|
+
results = {}
|
|
12
|
+
for loader, name, is_pkg in pkgutil.walk_packages(module_name.__path__, module_name.__name__ + "."):
|
|
13
|
+
try:
|
|
14
|
+
module = importlib.import_module(name)
|
|
15
|
+
|
|
16
|
+
if isinstance(has_attr_filter, str) and hasattr(module, has_attr_filter) or has_attr_filter is None:
|
|
17
|
+
results[name] = module
|
|
18
|
+
|
|
19
|
+
if recursive and is_pkg:
|
|
20
|
+
results.update(import_submodules(name, recursive, has_attr_filter))
|
|
21
|
+
|
|
22
|
+
except ImportError:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
return results
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_functions_by_prefix_from_modules(
|
|
29
|
+
module_names: Iterable[str], func_name_prefix: str, trim_func_name_prefix: bool = True
|
|
30
|
+
):
|
|
31
|
+
func_name_prefix = func_name_prefix.rstrip("_") + "_"
|
|
32
|
+
modules = {
|
|
33
|
+
mod_name: mod for module_name in module_names for mod_name, mod in import_submodules(module_name).items()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
functions_map = {
|
|
37
|
+
func_name[len(func_name_prefix) :] if trim_func_name_prefix else func_name: func
|
|
38
|
+
for module in modules.values()
|
|
39
|
+
for func_name, func in module.__dict__.items()
|
|
40
|
+
if isfunction(func) and func_name.startswith(func_name_prefix)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return functions_map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .columns import FlexColumnsExtension
|
|
2
|
+
from .flex_wrap import FlexWrapExtension
|
|
3
|
+
from .muted_text import MutedTextExtension
|
|
4
|
+
from .nerdfont_glyph import NerdfontGlyphExtension
|
|
5
|
+
from .page_break import PageBreakExtension
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"FlexColumnsExtension",
|
|
9
|
+
"FlexWrapExtension",
|
|
10
|
+
"MutedTextExtension",
|
|
11
|
+
"NerdfontGlyphExtension",
|
|
12
|
+
"PageBreakExtension",
|
|
13
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import xml.etree.ElementTree as ElementTree
|
|
2
|
+
|
|
3
|
+
from markdown import Extension
|
|
4
|
+
from markdown.inlinepatterns import InlineProcessor
|
|
5
|
+
|
|
6
|
+
# Pattern to match %% content %%
|
|
7
|
+
FLEX_COLUMNS_RE = r"%%\s*(.*?)\s*%%"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FlexColumnsInlineProcessor(InlineProcessor):
|
|
11
|
+
def __init__(self, pattern, md=None):
|
|
12
|
+
super().__init__(pattern, md)
|
|
13
|
+
|
|
14
|
+
def handleMatch(self, match, data):
|
|
15
|
+
# Extract the content between %% and %%
|
|
16
|
+
content = match.group(1)
|
|
17
|
+
|
|
18
|
+
columns = [col.strip() for col in content.split("||")]
|
|
19
|
+
|
|
20
|
+
container = ElementTree.Element("div")
|
|
21
|
+
container.set("class", "row")
|
|
22
|
+
for col_text in columns:
|
|
23
|
+
column = ElementTree.SubElement(container, "div")
|
|
24
|
+
column.set("class", "column")
|
|
25
|
+
column.text = col_text
|
|
26
|
+
|
|
27
|
+
return container, match.start(0), match.end(0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FlexColumnsExtension(Extension):
|
|
31
|
+
"""Markdown extension for the `%% col1 || col2 || ... %%` inline syntax.
|
|
32
|
+
|
|
33
|
+
Renders the given `||`-separated items as a `<div class="row">`
|
|
34
|
+
containing one `<div class="column">` per item, for simple side-by-side
|
|
35
|
+
layouts.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def extendMarkdown(self, md):
|
|
39
|
+
processor = FlexColumnsInlineProcessor(FLEX_COLUMNS_RE, md)
|
|
40
|
+
|
|
41
|
+
# Add with priority to ensure it runs before other inline processors
|
|
42
|
+
md.inlinePatterns.register(processor, "columns", 175)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# noinspection PyPep8Naming
|
|
46
|
+
def makeExtension(**kwargs):
|
|
47
|
+
return FlexColumnsExtension(**kwargs)
|