citry-django 0.1.0__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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ venv/
3
+ citry-src/
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ db.sqlite3
8
+ .static/
9
+ .media/
10
+ .pytest_cache/
11
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joey Jurjens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.5
2
+ Name: citry-django
3
+ Version: 0.1.0
4
+ Summary: Citry and Django's template engines, composed: real Django tags and filters inside Citry components, and Citry components inside Django templates.
5
+ Project-URL: Repository, https://github.com/joeyjurjens/citry-django
6
+ Project-URL: Issues, https://github.com/joeyjurjens/citry-django/issues
7
+ Author-email: Joey Jurjens <joeyjurjens@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: citry,components,django,templates
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 5.2
15
+ Classifier: Framework :: Django :: 6.0
16
+ Classifier: Framework :: Django :: 6.1
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.10
29
+ Requires-Dist: citry>=0.4.3
30
+ Requires-Dist: django>=5.2
31
+ Provides-Extra: compressor
32
+ Requires-Dist: citry-django-compressor; extra == 'compressor'
33
+ Provides-Extra: django-components
34
+ Requires-Dist: citry-django-djc; extra == 'django-components'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # citry-django
38
+
39
+ Citry and Django's template engines, composed.
40
+
41
+ ```python
42
+ from citry import Citry
43
+ from citry_django import CitryDjangoExtension
44
+
45
+ app = Citry(extensions=[CitryDjangoExtension()])
46
+ ```
47
+
48
+ ```python
49
+ # settings.py
50
+ TEMPLATES = [{"BACKEND": "citry_django.backend.CitryTemplates", "APP_DIRS": True, ...}]
51
+ CITRY_APP = "myproject.app:app"
52
+ ```
53
+
54
+ Any `{% tag %}` you can `{% load %}` in Django then works inside a Citry
55
+ component, and Citry components work in your existing Django templates. Neither
56
+ syntax changes, and the adapter names no tag: each engine's own parser decides
57
+ what belongs to it.
58
+
59
+ See the [repository README](https://github.com/joeyjurjens/citry-django) for the
60
+ full picture, the limits, and how it works.
61
+
62
+ ## Optional extras
63
+
64
+ ```bash
65
+ pip install citry-django[compressor]
66
+ ```
67
+
68
+ Routes each component's CSS and JS through [django-compressor], so assets are
69
+ preprocessed (SCSS, Less, ...) and minified. See `citry-django-compressor`.
70
+
71
+ [django-compressor]: https://django-compressor.readthedocs.io/
@@ -0,0 +1,35 @@
1
+ # citry-django
2
+
3
+ Citry and Django's template engines, composed.
4
+
5
+ ```python
6
+ from citry import Citry
7
+ from citry_django import CitryDjangoExtension
8
+
9
+ app = Citry(extensions=[CitryDjangoExtension()])
10
+ ```
11
+
12
+ ```python
13
+ # settings.py
14
+ TEMPLATES = [{"BACKEND": "citry_django.backend.CitryTemplates", "APP_DIRS": True, ...}]
15
+ CITRY_APP = "myproject.app:app"
16
+ ```
17
+
18
+ Any `{% tag %}` you can `{% load %}` in Django then works inside a Citry
19
+ component, and Citry components work in your existing Django templates. Neither
20
+ syntax changes, and the adapter names no tag: each engine's own parser decides
21
+ what belongs to it.
22
+
23
+ See the [repository README](https://github.com/joeyjurjens/citry-django) for the
24
+ full picture, the limits, and how it works.
25
+
26
+ ## Optional extras
27
+
28
+ ```bash
29
+ pip install citry-django[compressor]
30
+ ```
31
+
32
+ Routes each component's CSS and JS through [django-compressor], so assets are
33
+ preprocessed (SCSS, Less, ...) and minified. See `citry-django-compressor`.
34
+
35
+ [django-compressor]: https://django-compressor.readthedocs.io/
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "citry-django"
7
+ version = "0.1.0"
8
+ description = "Citry and Django's template engines, composed: real Django tags and filters inside Citry components, and Citry components inside Django templates."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Joey Jurjens", email = "joeyjurjens@gmail.com" }]
14
+ keywords = ["citry", "django", "templates", "components"]
15
+ dependencies = ["citry>=0.4.3", "django>=5.2"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Web Environment",
19
+ "Framework :: Django",
20
+ "Framework :: Django :: 5.2",
21
+ "Framework :: Django :: 6.0",
22
+ "Framework :: Django :: 6.1",
23
+ "Intended Audience :: Developers",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Operating System :: OS Independent",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.10",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Programming Language :: Python :: 3.14",
32
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
33
+ "Typing :: Typed",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ django-components = ["citry-django-djc"]
38
+ compressor = ["citry-django-compressor"]
39
+
40
+ [project.urls]
41
+ Repository = "https://github.com/joeyjurjens/citry-django"
42
+ Issues = "https://github.com/joeyjurjens/citry-django/issues"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/citry_django"]
@@ -0,0 +1,23 @@
1
+ """
2
+ citry_django -- Citry and Django's template engines, composed.
3
+
4
+ Each engine owns its own files and delegates the other's syntax to the other's
5
+ *real* engine, so both keep their full feature sets::
6
+
7
+ from citry import Citry
8
+ from citry_django import CitryDjangoExtension
9
+
10
+ app = Citry(extensions=[CitryDjangoExtension()])
11
+
12
+ Any ``{% tag %}`` you can ``{% load %}`` in Django then works inside a Citry
13
+ component, with no per-tag code. For the reverse direction -- Citry syntax in
14
+ your existing Django templates -- point ``settings.TEMPLATES`` at
15
+ ``citry_django.backend.CitryTemplates`` and set ``CITRY_APP``.
16
+ """
17
+
18
+ from .extension import CitryDjangoExtension, ForeignNode
19
+
20
+ __all__ = [
21
+ "CitryDjangoExtension",
22
+ "ForeignNode",
23
+ ]
@@ -0,0 +1,75 @@
1
+ """
2
+ A Django template backend that understands Citry element syntax.
3
+
4
+ Swap ``BACKEND`` in ``settings.TEMPLATES`` and ``<c-hero/>`` works in every
5
+ Django template::
6
+
7
+ TEMPLATES = [{
8
+ "BACKEND": "citry_django.backend.CitryTemplates",
9
+ "DIRS": [...],
10
+ "APP_DIRS": True,
11
+ "OPTIONS": {"context_processors": [...]},
12
+ }]
13
+
14
+ This is still Django's own engine: same lexer, same tags, same inheritance. The
15
+ only difference is that source is rewritten as it is read, so ``<c-component/>``
16
+ becomes a tag Django can compile. A template with no Citry syntax in it is
17
+ returned unchanged.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from django.template.backends.django import DjangoTemplates
25
+
26
+ from .rewrite import rewrite_source
27
+
28
+ _TAGS = "citry_django.templatetags.citry"
29
+
30
+
31
+ class _RewriteMixin:
32
+ """Rewrites Citry element syntax as a loader reads a template."""
33
+
34
+ def get_contents(self, origin: Any) -> str:
35
+ return rewrite_source(super().get_contents(origin), origin=origin.name)
36
+
37
+
38
+ def _enable_rewriting(loaders: Any) -> None:
39
+ """
40
+ Teach every configured loader to rewrite, whoever wrote it.
41
+
42
+ Django resolves ``get_contents`` on the loader that finally reads the file,
43
+ so rewriting is added there rather than by substituting loader classes. A
44
+ project that configures its own ``loaders`` -- for a component library, a
45
+ pattern library, or just to control caching -- keeps them and gets Citry
46
+ syntax in the templates they load.
47
+ """
48
+ for loader in loaders:
49
+ nested = getattr(loader, "loaders", None)
50
+ if nested:
51
+ _enable_rewriting(nested)
52
+ elif not isinstance(loader, _RewriteMixin):
53
+ cls = type(loader)
54
+ loader.__class__ = type(f"Rewriting{cls.__name__}", (_RewriteMixin, cls), {})
55
+
56
+
57
+ class CitryTemplates(DjangoTemplates):
58
+ def __init__(self, params: dict) -> None:
59
+ params = params.copy()
60
+ options = params.setdefault("OPTIONS", {}).copy()
61
+ params["OPTIONS"] = options
62
+
63
+ # The rewriter injects its tag into templates that have no `{% load %}`
64
+ # line of their own, so it has to resolve as a builtin.
65
+ builtins = list(options.get("builtins", []))
66
+ if _TAGS not in builtins:
67
+ builtins.append(_TAGS)
68
+ options["builtins"] = builtins
69
+
70
+ super().__init__(params)
71
+ _enable_rewriting(self.engine.template_loaders)
72
+
73
+ def from_string(self, template_code: str) -> Any:
74
+ # `from_string` never reaches a loader, so it rewrites here as well.
75
+ return super().from_string(rewrite_source(template_code, origin="<string template>"))
@@ -0,0 +1,86 @@
1
+ """
2
+ Deciding who owns a ``{{ ... }}`` inside a Citry template.
3
+
4
+ Both engines spell interpolation the same way, so the decision is made per
5
+ expression, at compile time:
6
+
7
+ 1. Not a valid Python expression -> Django (``{{ x|date:"Y-m-d" }}``).
8
+ 2. A plain dotted path -> Django, whose lookup is a superset of Python's
9
+ attribute access: dictionary, then attribute, then index. ``{{ d.key }}``
10
+ on a dict is why this matters.
11
+ 3. A filter chain whose filter names Django's live registry knows -> Django.
12
+ 4. Otherwise -> Citry.
13
+
14
+ Rule 3 is the only genuine overlap: ``a|b`` is both a filter application and a
15
+ bitwise or, resolved by asking the registry the template's own ``{% load %}``
16
+ lines populate. A Python variable named after a registered filter is therefore
17
+ read as the filter.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+ from functools import cache
24
+
25
+
26
+ def is_django_expression(expression: str, engine, extra_libraries: tuple[str, ...] = ()) -> bool:
27
+ """Whether ``{{ expression }}`` should be handed to Django."""
28
+ text = expression.strip()
29
+ if not text:
30
+ return True
31
+
32
+ try:
33
+ parsed = ast.parse(text, mode="eval")
34
+ except SyntaxError:
35
+ # Not Python at all: filter arguments (`|date:"Y-m-d"`), translation
36
+ # syntax, and so on.
37
+ return True
38
+
39
+ node = parsed.body
40
+
41
+ # A plain dotted path is Django's, because Django's lookup is a superset of
42
+ # Python's attribute access there: it tries dictionary, then attribute,
43
+ # then index. `{{ d.key }}` on a dict is the case that matters -- Python
44
+ # attribute access fails on it, Django resolves it, and every Django
45
+ # template in existence writes it that way.
46
+ if _is_dotted_path(node):
47
+ return True
48
+
49
+ if not (isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr)):
50
+ return False
51
+
52
+ known = _filter_names(engine, extra_libraries)
53
+ while isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
54
+ if not (isinstance(node.right, ast.Name) and node.right.id in known):
55
+ return False
56
+ node = node.left
57
+ return True
58
+
59
+
60
+ def _is_dotted_path(node: ast.AST) -> bool:
61
+ """
62
+ Whether the expression is only names joined by dots, e.g. ``a.b.c``.
63
+
64
+ A bare name is excluded: both engines resolve one identically, and leaving
65
+ it with Citry keeps Citry's strictness about unknown names. Anything with a
66
+ call, subscript or operator stays Citry's too, since Django cannot express
67
+ those at all.
68
+ """
69
+ if not isinstance(node, ast.Attribute):
70
+ return False
71
+ while isinstance(node, ast.Attribute):
72
+ node = node.value
73
+ return isinstance(node, ast.Name)
74
+
75
+
76
+ @cache
77
+ def _filter_names(engine, extra_libraries: tuple[str, ...]) -> frozenset[str]:
78
+ """Every filter name in scope: the engine's builtins plus loaded libraries."""
79
+ names: set[str] = set()
80
+ for library in engine.template_builtins:
81
+ names.update(library.filters)
82
+ for label in extra_libraries:
83
+ library = engine.template_libraries.get(label)
84
+ if library is not None:
85
+ names.update(library.filters)
86
+ return frozenset(names)