Skip to content

Commit ee096bc

Browse files
Add view for rendering component assets (#110)
1 parent 7c3e046 commit ee096bc

File tree

7 files changed

+233
-0
lines changed

7 files changed

+233
-0
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
1818

1919
## [Unreleased]
2020

21+
### Added
22+
23+
- Added built-in view and URLs for serving component assets in development. Note: This is not recommended for production use.
24+
2125
## [0.9.2]
2226

2327
### Changed

docs/assets.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,31 @@ Assets are collected from all components used in your template hierarchy:
108108
```
109109

110110
The `{% bird:css %}` tag will include CSS and the `[% bird:js %}` tag will include JavaScript from both the `nav` and `content` components.
111+
112+
## Serving Assets
113+
114+
### In Development
115+
116+
For development, django-bird includes a built-in view to serve component assets directly through Django. To enable this, add django-bird's URLs to your project's URL configuration:
117+
118+
```{code-block} python
119+
:caption: urls.py
120+
121+
from django.conf import settings
122+
from django.urls import include
123+
from django.urls import path
124+
125+
126+
if settings.DEBUG:
127+
urlpatterns = [
128+
path("__bird__/", include("django_bird.urls")),
129+
]
130+
```
131+
132+
This will make component assets available at `/__bird__/assets/<component_name>/<asset_filename>` when `settings.DEBUG` is `True`.
133+
134+
```{warning}
135+
The built-in asset serving is intended for development only. In production, you should serve assets through your web server or a CDN using Django's static files system.
136+
137+
Future integration with `django.contrib.staticfiles` and the `collectstatic` management command is planned.
138+
```

src/django_bird/components.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ class Component:
2626
template: DjangoTemplate
2727
assets: frozenset[Asset] = field(default_factory=frozenset)
2828

29+
def get_asset(self, asset_filename: str) -> Asset | None:
30+
for asset in self.assets:
31+
if asset.path.name == asset_filename:
32+
return asset
33+
return None
34+
2935
def render(self, context: dict[str, Any]):
3036
return self.template.render(context)
3137

src/django_bird/staticfiles.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ class AssetType(Enum):
1111
CSS = "css"
1212
JS = "js"
1313

14+
@property
15+
def content_type(self):
16+
match self:
17+
case AssetType.CSS:
18+
return "text/css"
19+
case AssetType.JS:
20+
return "application/javascript"
21+
1422
@property
1523
def ext(self):
1624
return f".{self.value}"

src/django_bird/urls.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from __future__ import annotations
2+
3+
from django.urls import path
4+
5+
from .views import asset_view
6+
7+
app_name = "django_bird"
8+
9+
urlpatterns = [
10+
path("assets/<str:component_name>/<str:asset_filename>", asset_view, name="asset"),
11+
]

src/django_bird/views.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
import warnings
4+
5+
from django.conf import settings
6+
from django.http import FileResponse
7+
from django.http import Http404
8+
from django.http.request import HttpRequest
9+
from django.template.exceptions import TemplateDoesNotExist
10+
11+
from .components import components
12+
13+
14+
def asset_view(request: HttpRequest, component_name: str, asset_filename: str):
15+
if not settings.DEBUG:
16+
warnings.warn(
17+
"Serving assets through this view in production is not recommended.",
18+
RuntimeWarning,
19+
stacklevel=2,
20+
)
21+
22+
try:
23+
component = components.get_component(component_name)
24+
except (KeyError, TemplateDoesNotExist) as err:
25+
raise Http404("Component not found") from err
26+
27+
asset = component.get_asset(asset_filename)
28+
if not asset or not asset.path.exists():
29+
raise Http404("Asset not found")
30+
31+
with open(asset.path, "rb") as f:
32+
content = f.read()
33+
34+
return FileResponse(content, content_type=asset.type.content_type)

tests/test_views.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
from http import HTTPStatus
4+
5+
import pytest
6+
from django.test import override_settings
7+
from django.urls import clear_url_caches
8+
from django.urls import include
9+
from django.urls import path
10+
from django.urls import reverse
11+
12+
from django_bird.staticfiles import AssetType
13+
14+
from .utils import TestAsset
15+
from .utils import TestComponent
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def debug_mode():
20+
with override_settings(DEBUG=True):
21+
yield
22+
23+
24+
@pytest.fixture(autouse=True)
25+
def setup_urls():
26+
urlpatterns = [
27+
path("__bird__/", include("django_bird.urls")),
28+
]
29+
30+
clear_url_caches()
31+
32+
with override_settings(
33+
ROOT_URLCONF=type(
34+
"urls",
35+
(),
36+
{"urlpatterns": urlpatterns},
37+
),
38+
):
39+
yield
40+
41+
clear_url_caches()
42+
43+
44+
def test_url_reverse(templates_dir):
45+
button = TestComponent(name="button", content="<button>Click me</button>").create(
46+
templates_dir
47+
)
48+
button_css = TestAsset(
49+
component=button,
50+
content=".button { color: blue; }",
51+
asset_type=AssetType.CSS,
52+
).create()
53+
54+
assert reverse(
55+
"django_bird:asset",
56+
kwargs={"component_name": button.name, "asset_filename": button_css.file.name},
57+
)
58+
59+
60+
def test_get_css(templates_dir, client):
61+
button = TestComponent(name="button", content="<button>Click me</button>").create(
62+
templates_dir
63+
)
64+
button_css = TestAsset(
65+
component=button,
66+
content=".button { color: blue; }",
67+
asset_type=AssetType.CSS,
68+
).create()
69+
70+
response = client.get(f"/__bird__/assets/{button.name}/{button_css.file.name}")
71+
72+
assert response.status_code == HTTPStatus.OK
73+
assert response["Content-Type"] == "text/css"
74+
75+
76+
def test_get_js(templates_dir, client):
77+
button = TestComponent(name="button", content="<button>Click me</button>").create(
78+
templates_dir
79+
)
80+
button_js = TestAsset(
81+
component=button, content="console.log('button');", asset_type=AssetType.JS
82+
).create()
83+
84+
response = client.get(f"/__bird__/assets/{button.name}/{button_js.file.name}")
85+
86+
assert response.status_code == HTTPStatus.OK
87+
assert response["Content-Type"] == "application/javascript"
88+
89+
90+
def test_get_invalid_type(templates_dir, client):
91+
button = TestComponent(name="button", content="<button>Click me</button>").create(
92+
templates_dir
93+
)
94+
95+
button_txt_file = button.file.parent / f"{button.file.stem}.txt"
96+
button_txt_file.write_text("hello from a text file")
97+
98+
response = client.get(f"/__bird__/assets/{button.name}/{button_txt_file.name}")
99+
100+
assert response.status_code == HTTPStatus.NOT_FOUND
101+
102+
103+
def test_get_nonexistent_component(templates_dir, client):
104+
bird_dir = templates_dir / "bird"
105+
bird_dir.mkdir(exist_ok=True)
106+
107+
nonexistent_css_file = bird_dir / "nonexistent.css"
108+
nonexistent_css_file.write_text(".button { color: blue; }")
109+
110+
response = client.get(f"/__bird__/assets/nonexistent/{nonexistent_css_file.name}")
111+
112+
assert response.status_code == HTTPStatus.NOT_FOUND
113+
114+
115+
def test_asset_view_nonexistent_asset(templates_dir, client):
116+
button = TestComponent(name="button", content="<button>Click me</button>").create(
117+
templates_dir
118+
)
119+
120+
response = client.get(f"/__bird__/assets/{button.name}/button.css")
121+
122+
assert response.status_code == HTTPStatus.NOT_FOUND
123+
124+
125+
def test_asset_view_warns_debug_false(templates_dir, client):
126+
button = TestComponent(name="button", content="<button>Click me</button>").create(
127+
templates_dir
128+
)
129+
button_css = TestAsset(
130+
component=button,
131+
content=".button { color: blue; }",
132+
asset_type=AssetType.CSS,
133+
).create()
134+
135+
with override_settings(DEBUG=False):
136+
with pytest.warns(RuntimeWarning):
137+
response = client.get(
138+
f"/__bird__/assets/{button.name}/{button_css.file.name}"
139+
)
140+
141+
assert response.status_code == HTTPStatus.OK
142+
assert response["Content-Type"] == "text/css"

0 commit comments

Comments
 (0)