Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ test-results/
Thumbs.db
.idea/
.vscode/
uv.lock
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Changelog

## [0.1.1] — 2026-04-28

### Fixed
- **Consumer compatibility regression**: `CfUiConfig.ready()` no longer
overrides `settings.COTTON_DIR`. The previous fix in 0.1.0 set
`COTTON_DIR="cotton/bulma"` globally, which broke any consumer project
whose own cotton templates lived at `templates/cotton/<their-app>/...`
(every `<c-foo.bar>` lookup got rewritten to
`cotton/bulma/foo/bar/index.html` and raised `TemplateDoesNotExist`).

### Changed
- cf-ui's cotton templates moved from `cotton/<theme>/cf/*.html` to
`cotton/cf/*.html`. Theme variation will now happen inside the templates
(or via `_themes/` partials) instead of at the directory level. With the
default `COTTON_DIR="cotton"`, `<c-cf.foo>` continues to resolve and
consumer cotton trees are no longer affected.

### Migration
- Most consumers: no change required. Removing any explicit
`COTTON_DIR="cotton/bulma"` from `settings.py` (added as a workaround
while 0.1.0 was broken) is recommended.
- If you imported `COTTON_TEMPLATES_DIR / "bulma"` directly via the escape
hatch, switch to `COTTON_TEMPLATES_DIR / "cf"`.

## [0.1.0] — 2026-04-25

### Added
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,18 @@ If you need direct access to template directories for custom configuration:
```python
from cf_ui import JINJA_TEMPLATES_DIR, COTTON_TEMPLATES_DIR

# JINJA_TEMPLATES_DIR / "bulma" → Path to Jinja2 templates
# COTTON_TEMPLATES_DIR / "bulma" → Path to Cotton templates
# JINJA_TEMPLATES_DIR / "bulma" → Path to Jinja2 templates (theme-prefixed)
# COTTON_TEMPLATES_DIR / "cf" → Path to Cotton component templates
```

> **Cotton templates are theme-agnostic on disk.** They live at
> `cotton/cf/*.html` (not `cotton/<theme>/cf/*.html`) so cf-ui can sit
> alongside any consumer project's own `templates/cotton/<app>/...` tree
> without colliding on `COTTON_DIR`. The active CSS framework is selected
> via `CF_UI_THEME` (used by the asset tags) — switching themes in a
> future release will happen inside the templates, not via the directory
> layout.

---

## Planned Themes
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "cf-ui"
version = "0.1.0"
version = "0.1.1"
description = "CSS framework UI kit for component-framework — Bulma, Bootstrap, Foundation, Fomantic, DaisyUI"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/cf_ui/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.1.1"
28 changes: 6 additions & 22 deletions src/cf_ui/django.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,14 @@
from pathlib import Path

from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured


class CfUiConfig(AppConfig):
name = "cf_ui.django"
label = "cf_ui"
verbose_name = "Component Framework UI"

def ready(self) -> None:
from django.conf import settings

theme = getattr(settings, "CF_UI_THEME", "bulma")
cotton_dir = Path(__file__).parent / "templates" / "cotton" / theme

if not cotton_dir.is_dir():
raise ImproperlyConfigured(
f"cf-ui: no templates found for theme {theme!r} at {cotton_dir}. "
f"Check CF_UI_THEME in settings."
)

# django-cotton reads COTTON_DIR (singular). Setting it to
# "cotton/<theme>" makes <c-cf.foo> resolve to
# cotton/<theme>/cf/foo.html, which the cotton loader finds via
# the app-templates walk (cf_ui/templates/cotton/<theme>/cf/foo.html).
# Don't overwrite a value the consumer has already set.
if not getattr(settings, "COTTON_DIR", None):
settings.COTTON_DIR = f"cotton/{theme}"
# cf-ui Cotton templates live at src/cf_ui/templates/cotton/cf/*.html.
# Django's app-templates loader (APP_DIRS=True) picks them up directly
# and django-cotton's default COTTON_DIR ("cotton") resolves
# <c-cf.foo> -> cotton/cf/foo.html. We deliberately do not touch
# COTTON_DIR here: doing so would break consumer projects whose own
# cotton templates live at cotton/<their-app>/*.html.
12 changes: 5 additions & 7 deletions tests/e2e/_e2e_django_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
Django settings for E2E tests only.

This module is used by the E2E server subprocess. It explicitly includes
django_cotton in INSTALLED_APPS and configures COTTON_DIR and
COTTON_SNAKE_CASED_NAMES so that cf-ui cotton components render fully.
django_cotton in INSTALLED_APPS so that cf-ui cotton components render fully.

It MUST NOT be imported by the main pytest process to avoid polluting
the shared Django settings used by unit and integration tests.
Expand All @@ -15,7 +14,7 @@

BASE_DIR = Path(__file__).parent.parent / "integration" / "cotton_app"

# cf-ui templates root — contains cotton/{theme}/cf/*.html
# cf-ui templates root — contains cotton/cf/*.html (theme-agnostic path)
CF_UI_TEMPLATES_ROOT = JINJA_TEMPLATES_DIR.parent.parent

SECRET_KEY = "e2e-test-secret-key-not-for-production"
Expand All @@ -32,7 +31,7 @@
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
CF_UI_TEMPLATES_ROOT, # provides cotton/bulma/cf/*.html
CF_UI_TEMPLATES_ROOT, # provides cotton/cf/*.html
BASE_DIR / "templates", # provides cotton_gallery/*.html
],
"APP_DIRS": True,
Expand All @@ -43,9 +42,8 @@
}
]
CF_UI_THEME = "bulma"
# COTTON_DIR: django-cotton resolves <c-cf.card> → cotton/bulma/cf/card.html
# With CF_UI_TEMPLATES_ROOT in DIRS, it finds src/cf_ui/templates/cotton/bulma/cf/card.html
COTTON_DIR = "cotton/bulma"
# django-cotton default COTTON_DIR="cotton" resolves <c-cf.card> ->
# cotton/cf/card.html, picked up via APP_DIRS from cf_ui's package templates.
# Allow hyphenated filenames (form-field.html, checkbox-group.html)
COTTON_SNAKE_CASED_NAMES = False
ROOT_URLCONF = "tests.integration.cotton_app.urls"
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/cotton/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ def cotton_render():
from django.template.loader import render_to_string

def _render(template_name: str, **props: object) -> str:
return render_to_string(f"cotton/bulma/{template_name}", props)
return render_to_string(f"cotton/{template_name}", props)

return _render
95 changes: 95 additions & 0 deletions tests/unit/test_consumer_compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""
Regression tests guarding against COTTON_DIR hijacking.

Background: an earlier fix (commit 5814476c) set ``COTTON_DIR="cotton/bulma"``
in ``CfUiConfig.ready()``. Because django-cotton uses ``COTTON_DIR`` as a
single global prefix for *every* ``<c-foo.bar>`` lookup, any consumer with
its own templates at ``templates/cotton/<their-app>/...`` immediately broke
once it added ``cf_ui.django.CfUiConfig`` to ``INSTALLED_APPS``.

cf-ui's templates now live at ``cotton/cf/*.html`` (no theme prefix), and
``CfUiConfig`` no longer touches ``COTTON_DIR``. These tests pin both
behaviors so the regression cannot return.
"""

from pathlib import Path


def test_cf_ui_does_not_set_cotton_dir():
"""CfUiConfig.ready() must not write to settings.COTTON_DIR."""
from django.conf import settings

cf_ui_managed_value = "cotton/bulma"
assert getattr(settings, "COTTON_DIR", None) != cf_ui_managed_value


def test_consumer_and_cf_ui_cotton_templates_resolve_together(tmp_path: Path):
"""A consumer's ``cotton/<app>/foo.html`` and cf-ui's ``cotton/cf/*.html``
must both resolve through the same Django template engine when
``cf_ui.django.CfUiConfig`` is installed.
"""
from django.apps import apps
from django.template.engine import Engine

# Django is configured + populated by tests/unit/conftest.py
# (which calls django.setup()). Verify cf_ui is registered as an app
# before testing template resolution.
assert apps.get_app_config("cf_ui") is not None

# Simulate a consumer's template tree at templates/cotton/myapp/foo.html
consumer_root = tmp_path / "templates"
consumer_template = consumer_root / "cotton" / "myapp" / "foo.html"
consumer_template.parent.mkdir(parents=True)
consumer_template.write_text("consumer-foo-content")

# Engine with APP_DIRS=True walks INSTALLED_APPS for templates,
# exactly like Django's default get_template() flow.
engine = Engine(
dirs=[str(consumer_root)],
app_dirs=True,
libraries={"cf_ui": "cf_ui.templatetags.cf_ui"},
)

from django.template import Context

# Consumer template at cotton/myapp/foo.html resolves cleanly —
# the regression made this raise TemplateDoesNotExist because
# the Cotton lookup got rewritten to cotton/bulma/myapp/foo.html.
consumer_t = engine.get_template("cotton/myapp/foo.html")
assert "consumer-foo-content" in consumer_t.render(Context({}))
assert str(consumer_template) == consumer_t.origin.name

# cf-ui's templates also resolve under the default Cotton path.
cf_ui_t = engine.get_template("cotton/cf/notification.html")
assert cf_ui_t is not None
# Sanity-check we found cf-ui's actual file, not a same-named consumer one.
assert "cf_ui" in (cf_ui_t.origin.name or "")


def test_all_cf_ui_cotton_components_resolve_at_cotton_cf_path():
"""Every cf-ui cotton component must be reachable at cotton/cf/<name>.html."""
from django.template.engine import Engine

engine = Engine(
app_dirs=True,
libraries={"cf_ui": "cf_ui.templatetags.cf_ui"},
)
expected = [
"breadcrumb",
"card",
"checkbox-group",
"form-field",
"modal",
"navbar",
"notification",
"pagination",
"panel",
"progress",
"select",
"table",
"tabs",
"textarea",
]
for name in expected:
t = engine.get_template(f"cotton/cf/{name}.html")
assert t is not None
16 changes: 12 additions & 4 deletions tests/unit/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ def test_cotton_templates_dir_exported():

assert isinstance(COTTON_TEMPLATES_DIR, Path)
assert COTTON_TEMPLATES_DIR.exists()
assert (COTTON_TEMPLATES_DIR / "bulma").exists()
assert (COTTON_TEMPLATES_DIR / "cf").exists()
assert (COTTON_TEMPLATES_DIR / "cf" / "card.html").exists()


def test_jinja_templates_dir_points_inside_package():
Expand All @@ -32,10 +33,17 @@ def test_django_appconfig_name():
assert app is not None


def test_django_appconfig_ready_sets_cotton_dir():
def test_django_appconfig_does_not_override_cotton_dir():
"""cf-ui must not set COTTON_DIR; doing so breaks consumer cotton trees.

cf-ui templates live at cotton/cf/*.html so the django-cotton default
(COTTON_DIR="cotton") resolves <c-cf.foo>. Consumers keep whatever value
they configured (or the default).
"""
from django.conf import settings

assert getattr(settings, "COTTON_DIR", None) == "cotton/bulma"
cf_ui_managed_value = "cotton/bulma"
assert getattr(settings, "COTTON_DIR", None) != cf_ui_managed_value


def test_fastapi_install_cf_ui_adds_template_dir():
Expand Down Expand Up @@ -80,4 +88,4 @@ def test_version_exported():
from cf_ui import __version__

assert isinstance(__version__, str)
assert __version__ == "0.1.0"
assert __version__ == "0.1.1"
Loading