├── .coveragerc ├── .flake8 ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .isort.cfg ├── LICENSE ├── README.md ├── demo ├── demo │ ├── __init__.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── settings.py │ ├── urls.py │ ├── views │ │ ├── __init__.py │ │ ├── colors │ │ │ ├── .py │ │ │ ├── __init__.py │ │ │ └── add.py │ │ ├── current-time.py │ │ └── not-a-view.py │ ├── views_test.py │ └── wsgi.py ├── manage.py └── requirements.txt ├── file_router ├── __init__.py └── file_router_test.py ├── pytest.ini ├── setup.cfg └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | demo/wsgi.py 4 | manage.py 5 | setup.py 6 | # Don't check for coverage in the temporary views folder at root. `demo/views/` should be covered 7 | views/* 8 | 9 | [report] 10 | show_missing = True 11 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .git, 4 | .venv, 5 | __pycache__, 6 | docs, 7 | dist, 8 | coverage, 9 | node, 10 | node_modules 11 | max-line-length = 88 12 | ignore = E203, E501, W503 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | pull_request: 5 | types: [reopened] 6 | 7 | concurrency: 8 | group: ${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-python@v2 18 | with: 19 | python-version: "3.10" 20 | - name: Install dependencies 21 | run: pip install -e ".[codestyle]" 22 | - name: Lint 23 | run: | 24 | isort --check-only . 25 | black --check . 26 | flake8 . 27 | pyupgrade --py37-plus **/*.py 28 | 29 | test: 30 | name: Test 31 | runs-on: ubuntu-latest 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | include: 36 | # Django 2.2 37 | - django: "2.2" 38 | python: "3.7" 39 | - django: "2.2" 40 | python: "3.8" 41 | - django: "2.2" 42 | python: "3.9" 43 | # Django 3.0 44 | - django: "3.0" 45 | python: "3.7" 46 | - django: "3.0" 47 | python: "3.8" 48 | - django: "3.0" 49 | python: "3.9" 50 | # Django 3.1 51 | - django: "3.1" 52 | python: "3.7" 53 | - django: "3.1" 54 | python: "3.8" 55 | - django: "3.1" 56 | python: "3.9" 57 | # Django 3.2 58 | - django: "3.2" 59 | python: "3.7" 60 | - django: "3.2" 61 | python: "3.8" 62 | - django: "3.2" 63 | python: "3.9" 64 | - django: "3.2" 65 | python: "3.10" 66 | # Django 4.0 67 | - django: "4.0" 68 | python: "3.8" 69 | - django: "4.0" 70 | python: "3.9" 71 | - django: "4.0" 72 | python: "3.10" 73 | steps: 74 | - uses: actions/checkout@v2 75 | - uses: actions/setup-python@v2 76 | with: 77 | python-version: ${{ matrix.python }} 78 | - name: Install dependencies 79 | run: | 80 | pip install -e ".[testing]" 81 | pip install "django~=${{ matrix.django }}" 82 | - name: Test 83 | run: pytest --cov-report=xml 84 | - uses: codecov/codecov-action@v2 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .venv 3 | .vscode 4 | /dist 5 | *.sqlite3 6 | *.egg-info 7 | __pycache__ 8 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile=black 3 | atomic=True 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ed Rivas 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django File Router 2 | 3 | File and folder-based routes for Django views. 4 | 5 | [![PyPi version](https://badgen.net/pypi/v/django-file-router/)](https://pypi.org/project/django-file-router/) 6 | ![PyPI Python versions](https://img.shields.io/pypi/pyversions/django-file-router.svg) 7 | ![PyPI Django versions](https://img.shields.io/pypi/djversions/django-file-router.svg) 8 | ![PyPI license](https://img.shields.io/pypi/l/django-file-router.svg) 9 | 10 | [![Test status](https://github.com/jerivas/django-file-router/actions/workflows/test.yml/badge.svg)](https://github.com/jerivas/django-file-router/actions/workflows/test.yml) 11 | [![Coverage](https://codecov.io/gh/jerivas/django-file-router/branch/main/graph/badge.svg?token=CGVTXOKQUW)](https://codecov.io/gh/jerivas/django-file-router) 12 | [![Formatted with Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://black.readthedocs.io/en/stable/) 13 | [![Follows Semantic Versioning](https://img.shields.io/badge/follows-SemVer-green.svg)](https://semver.org) 14 | 15 | 16 | ## Installation 17 | 18 | ``` 19 | pip install django-file-router 20 | ``` 21 | 22 | ## The problem 23 | 24 | Imagine you are creating a Django project with some CRUD views for your objects. How many files do you need to allow users to create a new object? 25 | 26 | 1. Create a form class in `forms.py` 27 | 2. Create a view function that imports the form in `views.py` 28 | 3. Create an HTML template that's referenced by the view somewhere in a `templates` directory 29 | 4. Edit `urls.py` to add your new view function 30 | 31 | That's a total of four files to accomplish something that to end users appears as a single action (add an object). On top of that you need to come up with a name for the form, the view, the template, and the url pattern even if they end up being some variation of `add_:object class:`. 32 | 33 | ## The solution 34 | 35 | Inspired by the popular JS frameworks like Next.js and Remix and the old-school convenience of PHP, `django-file-router` allows developers to store all form, template, and view code in a single file; while also inferring the appropriate URL patterns from the directory structure of these views. 36 | 37 | In practice it looks like this: 38 | 39 | ```python 40 | """ 41 |
42 | {% csrf_token %} 43 | {{ form.as_p }} 44 | 45 |
46 | """ 47 | 48 | from django import forms 49 | from django.shortcuts import redirect 50 | from file_router import render_str 51 | 52 | from myapp.models import MyModel 53 | 54 | class AddMyModelForm(forms.ModelForm): 55 | class Meta: 56 | model = MyModel 57 | fields = ("name", "description") 58 | 59 | def view(request): 60 | form = AddMyModelForm(request.POST or None) 61 | if request.method == "POST" and form.is_valid(): 62 | obj = form.save() 63 | return redirect(obj.get_absolute_url()) 64 | return render_str(__doc__, request, {"form": form}) 65 | ``` 66 | 67 | There's very little magic in this file. The template is stored at the top as a regular Python docstring and is later passed to the special function `render_str` at the bottom of the file. This function is identical to Django's `render` shortcut with the difference that the template code is passed directly as a string instead of a path. The only other hard requirement is that the file must expose a callable named `view` that accepts the request and returns a response. 68 | 69 | You would store this code in a file like `myapp/views/mymodel/add.py` and add this to your `urls.py`: 70 | 71 | ```python 72 | from file_router import file_patterns 73 | 74 | urlpatterns = [ 75 | path("admin/", admin.site.urls), 76 | *file_patterns("myapp/views"), 77 | ] 78 | ``` 79 | 80 | With that single call to `file_patterns` the function will generate URL patterns for all your views automatically based on their folder structure inside `myapp/views`. For the file we created earlier at `myapp/views/mymodel/add.py` this will result in a url of `/mymodel/add`. Then by simply creating more files and folders the URL patterns will be updated without any manual input. 81 | 82 | Here's an example folder structure for a complete CRUD workflow for `MyModel`: 83 | 84 | ``` 85 | myapp 86 | └── views 87 | └── mymodel 88 | ├── 89 | │   ├── delete.py 90 | │   ├── edit.py 91 | │   └── __init__.py 92 | ├── add.py 93 | └── __init__.py 94 | 95 | 3 directories, 5 files 96 | ``` 97 | 98 | This would generate the following URL patterns: 99 | 100 | - `/mymodel`: list of all instances 101 | - `/mymodel/add`: add a new instance 102 | - `/mymodel/`: view instance 103 | - `/mymodel//edit`: edit instance 104 | - `/mymodel//delete`: delete instance 105 | 106 | Each file now holds all the pieces required to perform a given action and requires much less context switching. 107 | 108 | 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: 109 | 110 | ```python 111 | """ 112 |

{{ obj.name }}

113 | """ 114 | 115 | from django.shortcuts import get_object_or_404 116 | from file_router import render_str 117 | 118 | from myapp.models import MyModel 119 | 120 | def view(request, id): 121 | obj = get_object_or_404(MyModel, id=id) 122 | return render_str(__doc__, request, {"obj": obj}) 123 | ``` 124 | 125 | More examples are available in the [demo folder](https://github.com/jerivas/django-file-router/tree/main/demo). 126 | 127 | ## Configuration 128 | 129 | The `file_patterns` function accepts the following arguments: 130 | 131 | | Arg | Description | 132 | |---|---| 133 | | `append_slash` | Boolean. If `True` will add a trailing slash to the generated patterns. | 134 | | `exclude` | String (glob). If set each file will be checked against this pattern and excluded from the pattern generation process altogether. Useful if you want to completely avoid importing certain files (like tests). | 135 | 136 | ## FAQ 137 | 138 | ### What about separation of concerns? 139 | 140 | I think that depends on how you define concerns. If you want to keep view, form, and template code separate then this approach goes against that, but in return you keep all code related to a particular user-facing functionality together, which is easier to reason about as this is how features are developed and maintained. 141 | 142 | ### What about syntax highlighting for the template code? 143 | 144 | Yes, currently template code is just a plain string, but I'm sure there's a way to create a custom language mode in IDEs that will highlight it as expected. I've seen it work in single file Vue components and GraphQL code in JS, so I know it's possible. 145 | 146 | ### What about named URL patterns? 147 | 148 | Every url pattern will also have an auto-generated url name. For example: 149 | 150 | | URL | URL name | 151 | |------------------------|---------------------| 152 | | `/mymodel` | `mymodel` | 153 | | `/mymodel/add` | `mymodel_add` | 154 | | `/mymodel/` | `mymodel_id` | 155 | | `/mymodel//edit` | `mymodel_id_edit` | 156 | | `/mymodel//delete` | `mymodel_id_delete` | 157 | 158 | Alternatively you can add `url` and `urlname` properties to your `view`: 159 | 160 | ```python 161 | def view(request): 162 | ... 163 | 164 | view.url = "custom/url/path" 165 | view.urlname = "my_custom_name" 166 | ``` 167 | 168 | ### Are you serious? 169 | 170 | Yes, kinda? This seems like a net positive gain in productivity and also reduces cognitive load, plus it's a pattern that's been tried and tested for years. It's also very light and doesn't require too much magic. So I plan to use it where I can and see if others show interest. 171 | -------------------------------------------------------------------------------- /demo/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerivas/django-file-router/91786d624c84d0a344acd9f9e7cc0d80791e90c2/demo/demo/__init__.py -------------------------------------------------------------------------------- /demo/demo/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.1 on 2022-02-01 04:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Color", 15 | fields=[ 16 | ( 17 | "id", 18 | models.BigAutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("created", models.DateTimeField(auto_now_add=True)), 26 | ("modified", models.DateTimeField(auto_now=True)), 27 | ("name", models.CharField(max_length=100)), 28 | ("slug", models.SlugField(max_length=100)), 29 | ("code", models.CharField(max_length=8)), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /demo/demo/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerivas/django-file-router/91786d624c84d0a344acd9f9e7cc0d80791e90c2/demo/demo/migrations/__init__.py -------------------------------------------------------------------------------- /demo/demo/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.db import models 3 | 4 | 5 | class Color(models.Model): 6 | created = models.DateTimeField(auto_now_add=True) 7 | modified = models.DateTimeField(auto_now=True) 8 | name = models.CharField(max_length=100) 9 | slug = models.SlugField(max_length=100) 10 | code = models.CharField(max_length=8) 11 | 12 | def __str__(self): # pragma: nocover 13 | return self.name 14 | 15 | 16 | admin.site.register(Color) 17 | -------------------------------------------------------------------------------- /demo/demo/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 4 | BASE_DIR = Path(__file__).resolve().parent.parent 5 | 6 | 7 | # Quick-start development settings - unsuitable for production 8 | # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ 9 | 10 | # SECURITY WARNING: keep the secret key used in production secret! 11 | SECRET_KEY = "django-insecure-p3alp*@far!9w*%bf*0va9e^3*pc)@c4^1gvd^%8nalwb4n_cr" 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = True 15 | 16 | ALLOWED_HOSTS = [] 17 | 18 | 19 | # Application definition 20 | 21 | INSTALLED_APPS = [ 22 | "demo", 23 | "django.contrib.admin", 24 | "django.contrib.auth", 25 | "django.contrib.contenttypes", 26 | "django.contrib.sessions", 27 | "django.contrib.messages", 28 | "django.contrib.staticfiles", 29 | ] 30 | 31 | MIDDLEWARE = [ 32 | "django.middleware.security.SecurityMiddleware", 33 | "django.contrib.sessions.middleware.SessionMiddleware", 34 | "django.middleware.common.CommonMiddleware", 35 | "django.middleware.csrf.CsrfViewMiddleware", 36 | "django.contrib.auth.middleware.AuthenticationMiddleware", 37 | "django.contrib.messages.middleware.MessageMiddleware", 38 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 39 | ] 40 | 41 | ROOT_URLCONF = "demo.urls" 42 | 43 | TEMPLATES = [ 44 | { 45 | "BACKEND": "django.template.backends.django.DjangoTemplates", 46 | "DIRS": [], 47 | "APP_DIRS": True, 48 | "OPTIONS": { 49 | "context_processors": [ 50 | "django.template.context_processors.debug", 51 | "django.template.context_processors.request", 52 | "django.contrib.auth.context_processors.auth", 53 | "django.contrib.messages.context_processors.messages", 54 | ], 55 | }, 56 | }, 57 | ] 58 | 59 | WSGI_APPLICATION = "demo.wsgi.application" 60 | 61 | 62 | # Database 63 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases 64 | 65 | DATABASES = { 66 | "default": { 67 | "ENGINE": "django.db.backends.sqlite3", 68 | "NAME": str(BASE_DIR / "db.sqlite3"), 69 | } 70 | } 71 | 72 | 73 | # Password validation 74 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators 75 | 76 | AUTH_PASSWORD_VALIDATORS = [ 77 | { 78 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 79 | }, 80 | { 81 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 82 | }, 83 | { 84 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 85 | }, 86 | { 87 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 88 | }, 89 | ] 90 | 91 | 92 | # Internationalization 93 | # https://docs.djangoproject.com/en/4.0/topics/i18n/ 94 | 95 | LANGUAGE_CODE = "en-us" 96 | 97 | TIME_ZONE = "UTC" 98 | 99 | USE_I18N = True 100 | 101 | USE_TZ = True 102 | 103 | 104 | # Static files (CSS, JavaScript, Images) 105 | # https://docs.djangoproject.com/en/4.0/howto/static-files/ 106 | 107 | STATIC_URL = "static/" 108 | 109 | # Default primary key field type 110 | # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field 111 | 112 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 113 | -------------------------------------------------------------------------------- /demo/demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from file_router import file_patterns 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | *file_patterns("demo/views"), 9 | ] 10 | -------------------------------------------------------------------------------- /demo/demo/views/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 |

Hello 👋

3 | 4 |

Here are some links:

5 | 9 | """ 10 | 11 | from file_router import render_str 12 | 13 | 14 | def view(request): 15 | return render_str(__doc__, request) 16 | 17 | 18 | view.urlname = "home" 19 | -------------------------------------------------------------------------------- /demo/demo/views/colors/.py: -------------------------------------------------------------------------------- 1 | """ 2 |

{{ color.name }}

3 |

The code is {{ color.code }}

4 | """ 5 | 6 | from django.shortcuts import get_object_or_404 7 | 8 | from demo.models import Color 9 | from file_router import render_str 10 | 11 | 12 | def view(request, slug): 13 | color = get_object_or_404(Color, slug=slug) 14 | return render_str(__doc__, request, {"color": color}) 15 | -------------------------------------------------------------------------------- /demo/demo/views/colors/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 |

Here's a list of all colors

3 | 4 | 9 | 10 |

Add a new color

11 | """ 12 | 13 | from demo.models import Color 14 | from file_router import render_str 15 | 16 | 17 | def view(request): 18 | colors = Color.objects.all() 19 | return render_str(__doc__, request, {"colors": colors}) 20 | -------------------------------------------------------------------------------- /demo/demo/views/colors/add.py: -------------------------------------------------------------------------------- 1 | """ 2 |

Add a new color

3 | 4 |
5 | {% csrf_token %} 6 | {{ form.as_p }} 7 | 8 |
9 | """ 10 | 11 | from django import forms 12 | from django.shortcuts import redirect 13 | from django.template.defaultfilters import slugify 14 | 15 | from demo.models import Color 16 | from file_router import render_str 17 | 18 | 19 | class ColorAddForm(forms.ModelForm): 20 | class Meta: 21 | model = Color 22 | fields = ("name", "code") 23 | 24 | def clean_name(self): 25 | name = self.cleaned_data["name"] 26 | if Color.objects.filter(name__iexact=name).exists(): 27 | raise forms.ValidationError("Name already taken!") 28 | return name 29 | 30 | def save(self, commit=True): 31 | self.instance.slug = slugify(self.instance.name) 32 | return super().save(commit) 33 | 34 | 35 | def view(request): 36 | form = ColorAddForm(request.POST or None) 37 | if request.method == "POST" and form.is_valid(): 38 | color = form.save() 39 | return redirect("colors_slug", color.slug) 40 | return render_str(__doc__, request, {"form": form}) 41 | -------------------------------------------------------------------------------- /demo/demo/views/current-time.py: -------------------------------------------------------------------------------- 1 | """ 2 | The current datetime is {% now "DATETIME_FORMAT" %} 3 | """ 4 | 5 | from file_router import render_str 6 | 7 | 8 | def view(request): 9 | return render_str(__doc__, request) 10 | -------------------------------------------------------------------------------- /demo/demo/views/not-a-view.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file shouldn't produce a URL pattern because it doesn't expose a `view` callable 3 | """ 4 | -------------------------------------------------------------------------------- /demo/demo/views_test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from django.urls import NoReverseMatch, reverse 5 | 6 | from demo.models import Color 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def change_test_dir(monkeypatch): 11 | """ 12 | For these tests change the CWD to the Django project root. This ensures the 13 | view folder location works as expected in the call to `file_patterns` in 14 | `urls.py`, even when pytest is called from the repo root. 15 | """ 16 | monkeypatch.chdir(str(Path(__file__).parent.parent)) 17 | 18 | 19 | @pytest.fixture 20 | def color(): 21 | return Color.objects.create(name="Foo Color", slug="foo", code="00ff00") 22 | 23 | 24 | def test_not_a_view(client): 25 | with pytest.raises(NoReverseMatch): 26 | reverse("not_a_view") 27 | 28 | response = client.get("/not-a-view") 29 | assert response.status_code == 404 30 | 31 | 32 | def test_home(client): 33 | url = reverse("home") 34 | assert ( 35 | url == "/" 36 | ), "Expected the file `views/__init__.py` to produce the url `/` with the name `home`" 37 | 38 | response = client.get(url) 39 | assert response.status_code == 200 40 | 41 | 42 | def test_current_time(client): 43 | url = reverse("current_time") 44 | assert ( 45 | url == "/current-time" 46 | ), "Expected the file `views/current-time.py` to produce the url `/current-time` with the name `current_time`" 47 | 48 | response = client.get(url) 49 | assert response.status_code == 200 50 | 51 | 52 | @pytest.mark.django_db 53 | def test_colors(client, color): 54 | url = reverse("colors") 55 | assert ( 56 | url == "/colors" 57 | ), "Expected the file `views/colors/__init__.py` to produce the url `/colors` with the name `colors`" 58 | 59 | response = client.get(url) 60 | assert response.status_code == 200 61 | assert color.name in str(response.content) 62 | 63 | 64 | @pytest.mark.django_db 65 | def test_colors_slug(client, color): 66 | url = reverse("colors_slug", args=[color.slug]) 67 | assert ( 68 | url == "/colors/foo" 69 | ), "Expected the file `views/colors/.py` to produce the url `/colors/` with the name `colors_slug`" 70 | 71 | response = client.get(url) 72 | assert response.status_code == 200 73 | assert color.name in str(response.content) 74 | assert color.code in str(response.content) 75 | 76 | 77 | @pytest.mark.django_db 78 | class TestColorsAdd: 79 | def test_get(self, client): 80 | url = reverse("colors_add") 81 | assert ( 82 | url == "/colors/add" 83 | ), "Expected the file `views/colors/add.py` to produce the url `/colors/add` with the name `colors_add`" 84 | 85 | response = client.get(url) 86 | assert response.status_code == 200 87 | assert b'=4,<4.1 2 | -e .. 3 | -------------------------------------------------------------------------------- /file_router/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import re 3 | from importlib import import_module 4 | 5 | from django.http import HttpResponse 6 | from django.template import RequestContext, Template 7 | from django.urls import path 8 | 9 | __version__ = "0.4.0" 10 | 11 | DISALLOWED_CHARS = re.compile( 12 | "|".join( 13 | [ 14 | r"^_+", # Leading underscores 15 | r"[<>]", # Angle brackets (url param wrapper) 16 | r"\w+\:", # Letters followed by colon (path converters) 17 | r"_+$", # Trailing underscores 18 | ] 19 | ) 20 | ) 21 | TO_UNDERSCORES = re.compile("[/-]") # Slash and dash 22 | 23 | 24 | def file_patterns(start_dir: str, append_slash: bool = False, exclude: str = ""): 25 | """ 26 | Create urlpatterns from a directory structure 27 | """ 28 | patterns = [] 29 | start_dir_re = re.compile(f"^{start_dir}") 30 | files = pathlib.Path(start_dir).glob("**/*.py") 31 | # Reverse-sort the list so files that start with "<" go to the bottom 32 | # and regular files come to the top. This ensures hard-coded url params 33 | # always match before variable ones like and 34 | files = sorted(files, reverse=True, key=str) 35 | for file in files: 36 | if exclude and pathlib.Path.match(file, exclude): 37 | continue 38 | 39 | module_path = str(file).replace(".py", "").replace("/", ".") 40 | module = import_module(module_path) 41 | view_fn = getattr(module, "view", None) 42 | if not callable(view_fn): 43 | continue 44 | 45 | try: 46 | url = view_fn.url 47 | except AttributeError: 48 | url = "" if file.name == "__init__.py" else file.name.replace(".py", "") 49 | url = start_dir_re.sub("", f"{file.parent}/{url}").strip("/") 50 | url = (url + "/") if append_slash and url != "" else url 51 | 52 | try: 53 | urlname = view_fn.urlname 54 | except AttributeError: 55 | urlname = DISALLOWED_CHARS.sub("", TO_UNDERSCORES.sub("_", url)) 56 | 57 | patterns.append(path(url, view_fn, name=urlname)) 58 | return patterns 59 | 60 | 61 | def render_str(source, request, context=None): 62 | """ 63 | Take a string and respond with a fully rendered template 64 | """ 65 | rendered = Template(source).render(RequestContext(request, context)) 66 | return HttpResponse(rendered) 67 | -------------------------------------------------------------------------------- /file_router/file_router_test.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | import pytest 4 | 5 | from . import file_patterns 6 | 7 | 8 | @pytest.fixture(scope="session", autouse=True) 9 | def copy_views(): 10 | """Copy the views folder of the demo project to this folder""" 11 | shutil.copytree("demo/demo/views", "views") 12 | yield 13 | shutil.rmtree("views") 14 | 15 | 16 | def test_append_slash(): 17 | patterns = file_patterns("views", append_slash=True, exclude="") 18 | output = [(str(p.pattern), p.name) for p in patterns] 19 | assert output == [ 20 | ("current-time/", "current_time"), 21 | ("colors/add/", "colors_add"), 22 | ("colors/", "colors"), 23 | ("colors//", "colors_slug"), 24 | ("", "home"), 25 | ] 26 | 27 | 28 | def test_exclude(): 29 | patterns = file_patterns("views", append_slash=False, exclude="*-time.py") 30 | output = [(str(p.pattern), p.name) for p in patterns] 31 | assert output == [ 32 | ("colors/add", "colors_add"), 33 | ("colors", "colors"), 34 | ("colors/", "colors_slug"), 35 | ("", "home"), 36 | ] 37 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = .* _* htmlcov 3 | DJANGO_SETTINGS_MODULE=demo.settings 4 | pythonpath = demo 5 | filterwarnings = 6 | ignore 7 | addopts = 8 | --tb=short 9 | --cov=. 10 | --cov-report=term:skip-covered 11 | --cov-fail-under=100 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-file-router 3 | version = attr: file_router.__version__ 4 | description = File and folder-based routes for Django views 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown; charset=UTF-8; variant=GFM 7 | author = Ed Rivas 8 | author_email = ed@jerivas.com 9 | url = https://github.com/jerivas/django-file-router 10 | project_urls = 11 | Documentation = https://github.com/jerivas/django-file-router#readme 12 | Source = https://github.com/jerivas/django-file-router 13 | Tracker = https://github.com/jerivas/django-file-router/issues 14 | Changelog = https://github.com/jerivas/django-file-router/releases 15 | Download = https://pypi.org/project/django-file-router/#files 16 | license = MIT 17 | classifiers = 18 | Development Status :: 4 - Beta 19 | Environment :: Web Environment 20 | Framework :: Django 21 | Intended Audience :: Developers 22 | Operating System :: OS Independent 23 | License :: OSI Approved :: MIT License 24 | Programming Language :: Python 25 | Programming Language :: Python :: 3 26 | Programming Language :: Python :: 3.7 27 | Programming Language :: Python :: 3.8 28 | Programming Language :: Python :: 3.9 29 | Programming Language :: Python :: 3.10 30 | Framework :: Django 31 | Framework :: Django :: 2.2 32 | Framework :: Django :: 3.0 33 | Framework :: Django :: 3.1 34 | Framework :: Django :: 3.2 35 | Framework :: Django :: 4.0 36 | 37 | [options] 38 | python_requires = >=3.7 39 | packages = file_router 40 | include_package_data = true 41 | install_requires = 42 | django >= 2.2 43 | 44 | [options.extras_require] 45 | testing = 46 | pytest-django >= 4, <5 47 | pytest-cov >= 2, < 3 48 | codestyle = 49 | flake8 >= 3, <4 50 | black >= 22, <23 51 | isort >= 5, <6 52 | pyupgrade >= 2, <3 53 | 54 | # Building 55 | 56 | [bdist_wheel] 57 | universal = 1 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | --------------------------------------------------------------------------------