diff --git a/README.md b/README.md index e5ebea5..3f9f2cf 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,9 @@ myapp ├── │   ├── delete.py │   ├── edit.py - │   └── index.py + │   └── __init__.py ├── add.py - └── index.py + └── __init__.py 3 directories, 5 files ``` @@ -105,7 +105,7 @@ This would generate the following URL patterns: Each file now holds all the pieces required to perform a given action and requires much less context switching. -Notice that special placeholders like `` are parsed as expected by Django's [`path`](https://docs.djangoproject.com/en/4.0/topics/http/urls/#how-django-processes-a-request) function, which means you can use path converters by including them in file and folder names such as ``. For example, to get a single instance enforcing an integer `id` create a file `myapp/views/mymodel//index.py` with the code: +Notice that special placeholders like `` are parsed as expected by Django's [`path`](https://docs.djangoproject.com/en/4.0/topics/http/urls/#how-django-processes-a-request) function, which means you can use path converters by including them in file and folder names such as ``. For example, to get a single instance enforcing an integer `id` create a file `myapp/views/mymodel//__init__.py` with the code: ```python """ diff --git a/demo/demo/urls_with_slash.py b/demo/demo/urls_with_slash.py deleted file mode 100644 index 02ace72..0000000 --- a/demo/demo/urls_with_slash.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Same as urls.py, but with `append_slash=True`""" - -from django.contrib import admin -from django.urls import path - -from file_router import file_patterns - -urlpatterns = [ - path("admin/", admin.site.urls), - *file_patterns("demo/views", append_slash=True), -] diff --git a/demo/demo/views_test.py b/demo/demo/views_test.py index 044d081..d9d53a0 100644 --- a/demo/demo/views_test.py +++ b/demo/demo/views_test.py @@ -1,9 +1,21 @@ +from pathlib import Path + import pytest from django.urls import NoReverseMatch, reverse from demo.models import Color +@pytest.fixture(autouse=True) +def change_test_dir(monkeypatch): + """ + For these tests change the CWD to the Django project root. This ensures the + view folder location works as expected in the call to `file_patterns` in + `urls.py`, even when pytest is called from the repo root. + """ + monkeypatch.chdir(str(Path(__file__).parent.parent)) + + @pytest.fixture def color(): return Color.objects.create(name="Foo Color", slug="foo", code="00ff00") @@ -17,14 +29,6 @@ def test_not_a_view(client): assert response.status_code == 404 -def test_append_slash(settings): - settings.ROOT_URLCONF = "demo.urls_with_slash" - assert reverse("home") == "/" - assert reverse("colors") == "/colors/" - assert reverse("colors_add") == "/colors/add/" - assert reverse("colors_slug", args=["abc"]) == "/colors/abc/" - - def test_home(client): url = reverse("home") assert ( diff --git a/file_router/__init__.py b/file_router/__init__.py index 342e120..c3c53c7 100644 --- a/file_router/__init__.py +++ b/file_router/__init__.py @@ -1,4 +1,4 @@ -import os +import pathlib import re from importlib import import_module @@ -21,40 +21,40 @@ TO_UNDERSCORES = re.compile("[/-]") # Slash and dash -def file_patterns(start_dir, append_slash=False): +def file_patterns(start_dir: str, append_slash: bool = False, exclude: str = ""): """ Create urlpatterns from a directory structure """ patterns = [] start_dir_re = re.compile(f"^{start_dir}") - for root, dirs, files in os.walk(start_dir): - # Reverse-sort the list so files that start with "<" go to the bottom - # and regular files come to the top. This ensures hard-coded url params - # always match before variable ones like and - files = sorted(files, reverse=True) - for file in files: - if not file.endswith(".py"): - continue + files = pathlib.Path(start_dir).glob("**/*.py") + # Reverse-sort the list so files that start with "<" go to the bottom + # and regular files come to the top. This ensures hard-coded url params + # always match before variable ones like and + files = sorted(files, reverse=True, key=str) + for file in files: + if exclude and pathlib.Path.match(file, exclude): + continue - module_path = f"{root}/{file}".replace(".py", "").replace("/", ".") - module = import_module(module_path) - view_fn = getattr(module, "view", None) - if not callable(view_fn): - continue + module_path = str(file).replace(".py", "").replace("/", ".") + module = import_module(module_path) + view_fn = getattr(module, "view", None) + if not callable(view_fn): + continue - try: - url = view_fn.url - except AttributeError: - url = "" if file == "__init__.py" else file.replace(".py", "") - url = start_dir_re.sub("", f"{root}/{url}").strip("/") - url = (url + "/") if append_slash and url != "" else url + try: + url = view_fn.url + except AttributeError: + url = "" if file.name == "__init__.py" else file.name.replace(".py", "") + url = start_dir_re.sub("", f"{file.parent}/{url}").strip("/") + url = (url + "/") if append_slash and url != "" else url - try: - urlname = view_fn.urlname - except AttributeError: - urlname = DISALLOWED_CHARS.sub("", TO_UNDERSCORES.sub("_", url)) + try: + urlname = view_fn.urlname + except AttributeError: + urlname = DISALLOWED_CHARS.sub("", TO_UNDERSCORES.sub("_", url)) - patterns.append(path(url, view_fn, name=urlname)) + patterns.append(path(url, view_fn, name=urlname)) return patterns diff --git a/file_router/file_router_test.py b/file_router/file_router_test.py new file mode 100644 index 0000000..96ff39c --- /dev/null +++ b/file_router/file_router_test.py @@ -0,0 +1,36 @@ +import shutil + +import pytest + +from . import file_patterns + + +@pytest.fixture(scope="session", autouse=True) +def copy_views(): + """Copy the views folder of the demo project to this folder""" + shutil.copytree("demo/demo/views", "views") + yield + shutil.rmtree("views") + + +def test_append_slash(): + patterns = file_patterns("views", append_slash=True, exclude="") + output = [(str(p.pattern), p.name) for p in patterns] + assert output == [ + ("current-time/", "current_time"), + ("colors/add/", "colors_add"), + ("colors/", "colors"), + ("colors//", "colors_slug"), + ("", "home"), + ] + + +def test_exclude(): + patterns = file_patterns("views", append_slash=False, exclude="*-time.py") + output = [(str(p.pattern), p.name) for p in patterns] + assert output == [ + ("colors/add", "colors_add"), + ("colors", "colors"), + ("colors/", "colors_slug"), + ("", "home"), + ]