types-for-jinja 1.0.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,486 @@
1
+ Metadata-Version: 2.4
2
+ Name: types_for_jinja
3
+ Version: 1.0.0
4
+ Summary: A mostly invisible type checking and LSP extension for Jinja, JinjaX, and similar templates, built on the pyright, ty, or mypy you already run
5
+ Keywords: calcipy_template
6
+ Author: Kyle King
7
+ Author-email: Kyle King <dev.act.kyle@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 1 - Planning
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: jinja2>=3.1.6
16
+ Requires-Dist: pygls>=1.3.0 ; extra == 'lsp'
17
+ Requires-Dist: pydantic>=2.0.0 ; extra == 'pydantic'
18
+ Requires-Dist: beartype>=0.18.0 ; extra == 'runtime'
19
+ Requires-Python: >=3.11.9
20
+ Project-URL: Bug Tracker, https://github.com/kyleking/types-for-jinja/issues
21
+ Project-URL: Changelog, https://github.com/kyleking/types-for-jinja/blob/main/docs/docs/CHANGELOG.md
22
+ Project-URL: Documentation, https://types-for-jinja.kyleking.me
23
+ Project-URL: Repository, https://github.com/kyleking/types-for-jinja
24
+ Provides-Extra: lsp
25
+ Provides-Extra: pydantic
26
+ Provides-Extra: runtime
27
+ Description-Content-Type: text/markdown
28
+
29
+ # types-for-jinja
30
+
31
+ A mostly invisible type checking and LSP extension for Jinja, JinjaX, and
32
+ similar templates, built on the pyright, ty, or mypy you already run.
33
+ Declare a template's context once, in a comment, and `types-for-jinja generate`
34
+ writes a line-aligned Python stub for it.
35
+ Your checker then reports every bad variable and attribute the template touches,
36
+ at the template's own line, in the same run that checks the rest of your code.
37
+ In the editor, the same stubs feed your Python language server, so templates get
38
+ inline diagnostics, completion, and hover without a second toolchain.
39
+ There is no new template language, Jinja renders unchanged, and nothing runs at
40
+ render time by default.
41
+
42
+ The only dependency is jinja2.
43
+ types-for-jinja never invokes a type checker itself, so template checks run
44
+ under your checker, your version, and your configuration, including checker
45
+ plugins such as the mypy pydantic plugin.
46
+ The "mostly" in invisible is raw CI output naming the stub instead of the
47
+ template; `types-for-jinja remap` and the editor mirror close that gap, and
48
+ "Limitations" lists the rest.
49
+
50
+ `types-for-jinja` is deliberately narrow (Jinja2 plus the dialects Jinja's own
51
+ parser reads).
52
+ If your needs differ, there are alternatives to consider:
53
+
54
+ - [TypeJinja](https://dl.acm.org/doi/10.1145/3786583.3786905) type-checks dbt's
55
+ Jinja against dbt's own IR and ships with the dbt fusion engine, so use it for
56
+ dbt projects
57
+ - [templ](https://github.com/a-h/templ) (Go),
58
+ [askama](https://github.com/askama-rs/askama) (Rust), and
59
+ [Twirl](https://github.com/playframework/twirl) (Scala) compile templates into
60
+ typed host-language functions, the ergonomic model this project borrows
61
+ - [JinjaX](https://github.com/jpsca/jinjax) adds component syntax to Jinja (the
62
+ `{#def #}` header comes from JinjaX) and composes with `types-for-jinja` rather
63
+ than replacing it
64
+ - [djlint](https://github.com/djlint/djLint) lints and formats template style
65
+ rather than types, so it runs alongside rather than instead
66
+
67
+ ## 30-second example
68
+
69
+ Add a header to a template naming its context:
70
+
71
+ ```jinja
72
+ {#def
73
+ from myapp.models import User
74
+ user: User
75
+ #}
76
+ <h1>Hello {{ user.naem }}</h1>
77
+ {% for item in user.items %}
78
+ <li>{{ item.titel }}</li>
79
+ {% endfor %}
80
+ ```
81
+
82
+ Generate the stubs, then run whichever checker the project already uses:
83
+
84
+ ```console
85
+ $ types-for-jinja generate templates/ # or just `generate`, with template_dirs set
86
+ types-for-jinja: Wrote 5 file(s) for 1 template(s)
87
+ $ ty check
88
+ _jinja_stubs/templates/greeting_html.py:5:5: error[unresolved-attribute] Object of type `User` has no attribute `naem`
89
+ _jinja_stubs/templates/greeting_html.py:7:9: error[unresolved-attribute] Object of type `Item` has no attribute `titel`
90
+ Found 2 diagnostics
91
+ ```
92
+
93
+ Generated line N is template line N, so the line numbers are the template's own.
94
+ Pipe through `remap` when you want the template's path and column too:
95
+
96
+ ```console
97
+ $ ty check | types-for-jinja remap
98
+ templates/greeting.html:5:14: error[unresolved-attribute] Object of type `User` has no attribute `naem`
99
+ templates/greeting.html:7:10: error[unresolved-attribute] Object of type `Item` has no attribute `titel`
100
+ Found 2 diagnostics
101
+ ```
102
+
103
+ A pipeline hands back the filter's exit code rather than the checker's, so in CI
104
+ let `remap` run the checker instead.
105
+ It exits with the checker's status, and with ty's default format the source
106
+ excerpt becomes the template's own line:
107
+
108
+ ```console
109
+ $ types-for-jinja remap -- ty check
110
+ error[unresolved-attribute]: Object of type `User` has no attribute `naem`
111
+ --> templates/greeting.html:5:14
112
+ |
113
+ 5 | <h1>Hello {{ user.naem }}</h1>
114
+ | ^^^^^^^^^
115
+ |
116
+ ```
117
+
118
+ `remap` reads text output from ty, mypy, and pyright, plus `pyright
119
+ --outputjson`, `mypy --output json`, and ty's `gitlab` and `github` formats.
120
+ A path outside the stub tree passes through untouched, so piping a whole-project
121
+ run leaves the project's own diagnostics exactly as the checker wrote them.
122
+
123
+ The `{#def #}` block is a plain Jinja comment, so the template renders exactly
124
+ as before.
125
+ The loop variable is narrowed to its element type, so `item.titel` is caught the
126
+ same way `user.naem` is.
127
+
128
+ A macro takes its own `{#def #}` block, which types its parameters for both its
129
+ body and its callers, across files:
130
+
131
+ ```jinja
132
+ {% macro field(label, value) %}
133
+ {#def
134
+ label: str
135
+ value: str
136
+ #}
137
+ <label>{{ label }}</label><span>{{ value }}</span>
138
+ {% endmacro %}
139
+ ```
140
+
141
+ Without that block the parameters stay untyped and only arity is checked.
142
+
143
+ JinjaX writes the whole context on one line, with commas between, defaults
144
+ allowed, and untyped names allowed.
145
+ Both spellings parse, and they can be mixed:
146
+
147
+ ```jinja
148
+ {#def action, method: str = "post", count: int = 0 #}
149
+ ```
150
+
151
+ A name with no annotation is `Any`, so it is declared without being constrained.
152
+ A default is carried into the generated wrapper's signature, so callers do not
153
+ have to repeat it.
154
+
155
+ JinjaX component tags are checked against the component's own header.
156
+ Point `template_dirs` at the component directory and a use is validated for
157
+ missing required attributes and for attribute types:
158
+
159
+ ```jinja
160
+ {#def title: str, count: int = 0 #} <!-- components/Card.jinja -->
161
+
162
+ <Card count={{ user.name }} class="wide" />
163
+ ```
164
+
165
+ ```console
166
+ $ types-for-jinja remap -- ty check
167
+ templates/page.html.jinja:6:16: error[missing-argument] No argument provided for required parameter `title`
168
+ templates/page.html.jinja:6:7: error[invalid-argument-type] Expected `int`, found `str`
169
+ ```
170
+
171
+ An attribute the component does not declare is not an error, because JinjaX
172
+ forwards it to the component as `attrs`, which is what `class="wide"` above
173
+ relies on.
174
+
175
+ A tag an extension adds does not parse until the extension is loaded, and an
176
+ unparseable template is skipped entirely, so its own errors go unreported too.
177
+ Declaring the extension is what makes the rest of the template checkable:
178
+
179
+ ```toml
180
+ [tool.types_for_jinja]
181
+ extensions = ["do", "i18n", "loopcontrols"]
182
+ ```
183
+
184
+ `i18n` also declares the names it injects (`_`, `gettext`, `ngettext`,
185
+ `pgettext`, `npgettext`), so a template using them needs nothing else.
186
+ Jinja's own globals (`namespace`, `cycler`, `joiner`, `lipsum`) are declared
187
+ automatically when a template names one, including the `{% set ns.total = ...
188
+ %}` form.
189
+
190
+ Jinja's built-in filters carry their return type, so a filtered expression is
191
+ still checked:
192
+ `{{ items | length }}` is an `int`, and `{% for x in items | sort %}` still
193
+ knows what `x` is.
194
+ Only the return type is pinned, because a filter catalog that guesses at
195
+ argument types reports errors on correct templates.
196
+ A filter the catalog does not know (yours, or one from an extension) falls back
197
+ to `Any`.
198
+
199
+ ## Installation
200
+
201
+ ```console
202
+ uv add types-for-jinja # or: pip install types-for-jinja
203
+ ```
204
+
205
+ That installs jinja2 and nothing else.
206
+ Bring your own checker:
207
+ pyright, ty, and mypy are each verified against the generated stubs on every CI
208
+ run, and anything that reads standard Python annotations should work the same
209
+ way.
210
+
211
+ ## How it works
212
+
213
+ `types-for-jinja generate` parses each template with Jinja's own parser and
214
+ writes a small Python module that exercises every expression the template uses,
215
+ preserving nesting so your checker's scoping and narrowing mirror Jinja's.
216
+ `{% for item in items %}` becomes a real `for` loop, so the checker infers the
217
+ element type.
218
+ The stub tree mirrors the template tree under `_jinja_stubs/` (only the filename
219
+ is mangled, because a module name cannot carry a template extension), and a
220
+ manifest maps each stub back to its template.
221
+ Environment globals such as `static_url` are declared once under
222
+ `[tool.types_for_jinja]` in `pyproject.toml` so they never show up as undefined.
223
+
224
+ Commit the stubs and a fresh clone type-checks its templates with no
225
+ types-for-jinja run at all.
226
+ Add `types-for-jinja generate --check` to pre-commit or CI to fail when a stub
227
+ is missing or out of date; the shipped pre-commit hook does exactly that.
228
+
229
+ Cross-file constructs resolve at generation time:
230
+ a child checks against its whole `{% extends %}` chain, an `{% include %}` body
231
+ checks against the including template's context, and `{% import %}`-ed macros
232
+ carry their own `{#def #}` types to their callers.
233
+
234
+ ## In your editor
235
+
236
+ The stubs are ordinary workspace Python, so the Python language server you
237
+ already run flags them with no setup:
238
+ open the stub and the error is on the same line number as the template.
239
+ Two layers make that invisible:
240
+
241
+ - The `types-for-jinja-lsp` server (the `lsp` extra) attaches to template buffers
242
+ and regenerates the stub as you type, debounced, so your Python checker
243
+ re-checks it live before you save.
244
+ It also completes and describes the typed context in the template itself:
245
+ the names visible at the cursor, the members of their types after a `.`,
246
+ built-in filters after `|` (with return types), tests after `is`, and tags after
247
+ `{%`, plus hover for all of them.
248
+ - A thin mirror republishes the stub's diagnostics onto the template buffer, line
249
+ for line, so errors appear inline in the template with your checker's own codes.
250
+ Mirroring has to live in the editor, because one language server cannot read
251
+ another server's diagnostics.
252
+ `editors/nvim` ships it for Neovim.
253
+ VS Code, Cursor, Zed, and Helix need the same layer against their own diagnostic
254
+ APIs, and none of that is built.
255
+
256
+ Attribute completion asks a Python language server what the expression's type
257
+ offers, taking the first of pyright, basedpyright, `ty server`, pylsp, or jedi
258
+ found on `PATH`.
259
+ Pin one with `[tool.types_for_jinja] language_server` if you run several.
260
+ When none is installed, member completion is simply absent and everything else
261
+ still works.
262
+
263
+ ## Configuration
264
+
265
+ Everything lives under `[tool.types_for_jinja]` in `pyproject.toml`, and every
266
+ setting has a working default.
267
+
268
+ | Setting | Default | What it does |
269
+ | ----------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
270
+ | `globals` | none | Names your `Environment.globals` injects, as a `name = "Type"` table, so `static_url()` is not a false positive |
271
+ | `imports` | none | Import lines the generated stubs need to resolve the types named in `globals` |
272
+ | `out_dir` | `_jinja_stubs` | Where stubs go. Rejected unless every path segment is an identifier, since the stubs import each other relatively |
273
+ | `suppression` | `portable` | Which ignore comment `{# type: ignore #}` becomes: `portable`, `mypy`, `pyright`, or `ty` |
274
+ | `template_dirs` | none | Where `{% extends %}`, `{% include %}`, and `{% import %}` are resolved from, and what `generate` searches when given no paths. Accepts `package:subdirectory` |
275
+ | `template_globs` | `*.html`, `*.jinja`, `*.j2` | Patterns a directory argument is searched for |
276
+ | `language_server` | first found | Pins the language server attribute completion asks, instead of taking the first on `PATH` |
277
+ | `extensions` | none | jinja2 extensions to load so their tags parse: `debug`, `do`, `i18n`, `loopcontrols` |
278
+ | `syntax` | Jinja's own | Delimiters, using `jinja2.Environment`'s own keyword names |
279
+ | `wrapper` | see below | Options for `types-for-jinja wrapper` |
280
+
281
+ ```toml
282
+ [tool.types_for_jinja]
283
+ imports = ["from collections.abc import Callable"]
284
+ template_dirs = ["myapp:templates"]
285
+
286
+ [tool.types_for_jinja.globals]
287
+ static_url = "Callable[[str], str]"
288
+ current_route = "str"
289
+ ```
290
+
291
+ ## Suppressing a diagnostic
292
+
293
+ An inline `{# type: ignore #}` in the template becomes a blanket ignore comment
294
+ on the generated line.
295
+ Checkers spell that differently, so name yours if you run only one:
296
+
297
+ ```toml
298
+ [tool.types_for_jinja]
299
+ suppression = "ty" # portable (default), mypy, pyright, or ty
300
+ ```
301
+
302
+ The default emits `# type: ignore`, which pyright, ty, and mypy all honour.
303
+ Naming a checker emits only what that checker reads, so `pyright` writes `#
304
+ pyright: ignore` and mypy will not honour it.
305
+ Suppression is blanket per line rather than per code, because rule codes differ
306
+ between checkers and a wrong one fails to suppress.
307
+
308
+ ## Typed render calls (optional)
309
+
310
+ `generate` types the inside of a template.
311
+ `types-for-jinja wrapper` types the call site, so `render_profile(porfile=...)`
312
+ fails your checker the same way a typo in the template body does:
313
+
314
+ ```console
315
+ $ types-for-jinja wrapper templates/ -o myapp/_render \
316
+ --env-import 'from myapp.templating import env as _env'
317
+ types-for-jinja: Wrote 3 of 3 wrapper file(s)
318
+ ```
319
+
320
+ Each template gets one function whose signature is its `{#def #}` header, and
321
+ whose body calls Jinja unchanged:
322
+
323
+ ```python
324
+ @beartype
325
+ def render_profile(*, profile: Profile) -> Markup:
326
+ """Render profile.html.jinja with a checked context."""
327
+ return Markup(_env.get_template('profile.html.jinja').render(profile=profile))
328
+ ```
329
+
330
+ Set the options once in `pyproject.toml` instead of passing them every run:
331
+
332
+ ```toml
333
+ [tool.types_for_jinja]
334
+ template_dirs = ["myapp/templates"]
335
+
336
+ [tool.types_for_jinja.wrapper]
337
+ env_import = "from myapp.templating import env as _env"
338
+ out_dir = "myapp/_render"
339
+ validator = "beartype"
340
+ ```
341
+
342
+ `template_dirs` is what makes the generated `get_template()` argument match the
343
+ name your loader uses.
344
+ Run `types-for-jinja wrapper --check` in CI or a pre-commit hook to fail when a
345
+ generated wrapper no longer matches its template.
346
+
347
+ Web apps usually return a response rather than `Markup`.
348
+ Point `--return-type` and `--return-import` (or `return_type` and
349
+ `return_import` in the config) at your framework's class and the wrapper calls
350
+ it instead:
351
+
352
+ ```python
353
+ def render_profile(*, profile: Profile) -> HTMLResponse:
354
+ """Render profile.html.jinja with a checked context."""
355
+ return HTMLResponse(_env.get_template('profile.html.jinja').render(profile=profile))
356
+ ```
357
+
358
+ A helper that does real work before rendering, or sets a status code, stays
359
+ hand-written and calls the generated function for the render itself.
360
+
361
+ ### Runtime checking
362
+
363
+ `--validator` adds render-time enforcement on top:
364
+ [beartype](https://github.com/beartype/beartype) checks the value against the
365
+ annotation and raises, [Pydantic](https://github.com/pydantic/pydantic) parses
366
+ and coerces it through a `TypeAdapter`.
367
+ Both work whether your context types are dataclasses or Pydantic models.
368
+ The default is `none`, because the static check costs nothing at runtime.
369
+ Pydantic can hand the template a new coerced object, so the value you pass is
370
+ not always the value rendered; beartype leaves the object alone.
371
+ A runnable proof of both lives in `examples/runtime`.
372
+
373
+ ## Scope
374
+
375
+ `types-for-jinja` checks anything Jinja's own parser reads:
376
+ plain Jinja2, JinjaX, and the templates in Flask, Litestar, FastAPI, Copier, and
377
+ Cookiecutter projects.
378
+ A superset that changes Jinja's delimiters declares them once, using the same
379
+ names `jinja2.Environment` uses:
380
+
381
+ ```toml
382
+ [tool.types_for_jinja.syntax]
383
+ variable_start_string = "[["
384
+ variable_end_string = "]]"
385
+ ```
386
+
387
+ Copier and Cookiecutter put Jinja expressions in directory names.
388
+ A stub tree mangles a directory name that is not already a valid identifier, so
389
+ `template/{{ module_name }}/__init__.py.jinja` is checkable and still reported
390
+ at its real path.
391
+ Set `template_globs` when the templates are not `.html`, `.jinja`, or `.j2`:
392
+
393
+ ```toml
394
+ [tool.types_for_jinja]
395
+ template_dirs = ["{{cookiecutter.project_slug}}"]
396
+ template_globs = ["*.md", "*.py", "*.toml"]
397
+ ```
398
+
399
+ Templates shipped inside an installed package (what `jinja2.PackageLoader`
400
+ loads) are reachable with a `package:subdirectory` entry in `template_dirs`,
401
+ alongside plain paths:
402
+
403
+ ```toml
404
+ [tool.types_for_jinja]
405
+ template_dirs = ["myapp:templates", "local/templates"]
406
+ ```
407
+
408
+ Out of scope on purpose:
409
+ Ansible, Salt, and dbt (untyped runtime contexts and large custom filter
410
+ libraries, and dbt already has TypeJinja), engines not hosted in Python
411
+ (Nunjucks, Twig, Liquid, Handlebars), and Python engines with different lookup
412
+ semantics (Django's DTL, Mako, Chameleon).
413
+ [DESIGN] gives the reasoning for each.
414
+
415
+ ## Limitations
416
+
417
+ - Raw checker output names the stub, not the template.
418
+ The line number is the template's own, and the stub tree mirrors the template
419
+ tree, so the mapping reads at a glance; `remap` recovers the path and column for
420
+ CI logs, and the editor mirror recovers them inline.
421
+ Python has no equivalent of Go's `//line` directive, which is why the path
422
+ cannot be fixed at the source.
423
+ - Stubs must be regenerated when templates change.
424
+ `generate --check` in pre-commit or CI catches a stale one; the LSP regenerates
425
+ on edit.
426
+ - A template with no line-aligned form (rare; measured under 3% on real template
427
+ sets) falls back to `# L<n>` markers, which `remap` and the mirror still read,
428
+ and raw checker output does not.
429
+ - Filter and test argument types are unchecked; only built-in return types are
430
+ pinned, and unknown filters widen to `Any`.
431
+ - A JinjaX component tag that cannot be resolved to a file is skipped, which
432
+ includes any tag carrying a catalog prefix (`<ui:Button />`), because the prefix
433
+ maps to a search path that lives in the catalog rather than in the template.
434
+ - Only jinja2's own extensions can be declared.
435
+ A project-defined extension would mean importing project code to parse a
436
+ template, and a custom tag's meaning is not inferable from its parser hook, so
437
+ those templates are skipped with a warning.
438
+ - Templates that exist only behind a `DictLoader` or a database still need a copy
439
+ on disk to be checked.
440
+
441
+ ## Project Status
442
+
443
+ Early and moving.
444
+ [DESIGN] holds the settled decisions, the scope boundary, and the measurements
445
+ they rest on; [BLUE_SKY] holds unscheduled work and why each item is waiting.
446
+ See also the `Open Issues` and the [CODE_TAG_SUMMARY].
447
+ For release history, see the [CHANGELOG].
448
+
449
+ ## Contributing
450
+
451
+ We welcome pull requests!
452
+ For your pull request to be accepted smoothly, we suggest that you first open a
453
+ GitHub issue to discuss your idea.
454
+ For resources on getting started with the code base, see the below
455
+ documentation:
456
+
457
+ - [DEVELOPER_GUIDE]
458
+ - [STYLE_GUIDE]
459
+
460
+ ## Code of Conduct
461
+
462
+ We follow the [Contributor Covenant Code of Conduct][contributor-covenant].
463
+
464
+ ### Open Source Status
465
+
466
+ We try to reasonably meet most aspects of the "OpenSSF scorecard" from
467
+ [Open Source Insights](https://deps.dev/pypi/types-for-jinja)
468
+
469
+ ## Responsible Disclosure
470
+
471
+ If you have any security issue to report, please contact the project maintainers
472
+ privately.
473
+ You can reach us at [dev.act.kyle@gmail.com](mailto:dev.act.kyle@gmail.com).
474
+
475
+ ## License
476
+
477
+ [LICENSE]
478
+
479
+ [blue_sky]: https://github.com/kyleking/types-for-jinja/blob/main/docs/BLUE_SKY.md
480
+ [changelog]: https://types-for-jinja.kyleking.me/docs/CHANGELOG
481
+ [code_tag_summary]: https://types-for-jinja.kyleking.me/docs/CODE_TAG_SUMMARY
482
+ [contributor-covenant]: https://www.contributor-covenant.org
483
+ [design]: https://github.com/kyleking/types-for-jinja/blob/main/docs/DESIGN.md
484
+ [developer_guide]: https://types-for-jinja.kyleking.me/docs/DEVELOPER_GUIDE
485
+ [license]: https://github.com/kyleking/types-for-jinja/blob/main/LICENSE
486
+ [style_guide]: https://types-for-jinja.kyleking.me/docs/STYLE_GUIDE