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 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,5 @@
1
+ from .accessor import DBAccessor
2
+ from .importer import DataImporter
3
+ from .model_field_typing import ModelType
4
+
5
+ __all__ = ["DBAccessor", "DataImporter", "ModelType"]
@@ -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,3 @@
1
+ from .rendering import create_markdown_renderer
2
+
3
+ __all__ = ["create_markdown_renderer"]
@@ -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)