Skip to content

Commit

Permalink
feat: initial setup
Browse files Browse the repository at this point in the history
  • Loading branch information
Sven Groot committed Dec 14, 2024
0 parents commit 52c1518
Show file tree
Hide file tree
Showing 15 changed files with 775 additions and 0 deletions.
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

.coverage
.pytest_cache
.ruff_cache
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
9 changes: 9 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
MIT License

Copyright 2024 Sven Groot

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# django-ts-routes

**django-ts-routes** is a Django application allowing to expose and perform reverse lookups of Django named URL patterns in a TypeScript code base. This codebase is based on [django-js-routes](https://github.com/ellmetha/django-js-routes). Big thanks to the original author of this package.

## Table of Contents

- [Installation](#installation)
- [Usage](#usage)
- [Settings](#settings)
- [Advanced features](#advanced-features)
- [License](#license)

## Installation

To install django-ts-routes, please use the pip command as follows:

```shell
$ pip install django-ts-routes
```

Once the package is installed, you'll have to add the application to `INSTALLED_APPS` in your project's settings module:

```python
INSTALLED_APPS = (
# all other apps...
'ts_routes',
)
```

You can then define which URL patterns or URL namespaces you want to expose by setting the `TS_ROUTES_INCLUSION_LIST` setting (for compatibility with [django-js-routes](https://github.com/ellmetha/django-js-routes) you can also use `JS_ROUTES_INCLUSION_LIST`). This setting allows to define which URLs should be serialized and made available to the client side through the generated and / or exported TypeScript helper. This list should contain only URL pattern names or namespaces. Here is an example:

```python
TS_ROUTES_INCLUSION_LIST = [
'home',
'catalog:product_list',
'catalog:product_detail',
]
```

Note that if a namespace is included in this list, all the underlying URLs will be made available to the client side through the generated TypeScript helper. Django-ts-routes is safe by design in the sense that _only_ the URLs that you configure in this inclusion list will be publicly exposed on the client side.

Once the list of URLs to expose is configured, you can dump the routes with the management command `dump_routes_resolver`:

```shell
$ python manage.py dump_routes_resolver --output-dir=static/src/routes
```

## Usage

The URL patterns you configured through the `TS_ROUTES_INCLUSION_LIST` setting will be exported to TypeScript files in the `output-dir` directory. This directory will contain a `index.ts` with routes of the default language, other supported languages will be in separate files. The files export the `reverseUrl` function that can be used to generate URLs in your TypeScript code.

```typescript
import reverseUrl from "@/routes";

reverseUrl("home");
reverseUrl("catalog:product_list");
reverseUrl("catalog:product_detail", { pk: productId });
```

## Settings

### TS_ROUTES_INCLUSION_LIST

Default: `[]`

The `TS_ROUTES_INCLUSION_LIST` setting allows to define the URL patterns and URL namespaces that should be exposed.

## License

MIT. See `LICENSE` for more details.
75 changes: 75 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
[project]
name = "django-ts-routes"
version = "0.1.0.dev0"
description = "Expose and perform reverse lookups of Django URLs in the TypeScript world."
readme = "README.md"
license = {text = "MIT"}
authors = [
{ name = "Sven Groot" }
]
requires-python = ">=3.8"
keywords = ["django", "urls", "reverse", "typescript", "export"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Framework :: Django",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"django >= 4.2",
]

[dependency-groups]
dev = [
"pytest-cov==5.0.0",
"pytest==8.3.4",
"ruff==0.8.3",
"pytest-django==4.9.0",
]

[project.urls]
homepage = "https://github.com/svengt/django-ts-routes"
repository = "https://github.com/svengt/django-ts-routes"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/ts_routes"]

[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple/"
publish-url = "https://upload.pypi.org/legacy/"

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"

[tool.ruff.lint]
select = [
"E",
"F",
"B",
"SIM",
"I",
]
ignore = ["E501"]
unfixable = ["B", "SIM"]

[tool.pytest.ini_options]
django_find_project = false
addopts = "--ds=tests.pytest.settings --reuse-db"
pythonpath = "./"
2 changes: 2 additions & 0 deletions src/ts_routes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def hello() -> str:
return "Hello from django-ts-routes!"
Empty file.
Empty file.
39 changes: 39 additions & 0 deletions src/ts_routes/management/commands/dump_routes_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from os import path

from django.conf import settings
from django.core.management.base import BaseCommand
from django.template.loader import render_to_string
from django.utils import translation

from ...serializers import url_patterns_serializer


class Command(BaseCommand):
"""Dump Django URLs and the resolver helper to a TS file per language."""

help = "Dump Django URLs and the resolver helper to a TS file per language."

def add_arguments(self, parser):
parser.add_argument(
"-o",
"--output-dir",
required=True,
help="Specifies a directory to which the output is written.",
)

def _write_resolver_file(self, filename):
result = render_to_string(
"ts_routes/_dump/resolver.ts",
{"routes": url_patterns_serializer.to_json()},
)
with open(filename, "w") as output:
output.write(result)

def handle(self, *args, **options):
filename = path.join(options["output_dir"], "index.ts")
self._write_resolver_file(filename)

for lang, _ in settings.LANGUAGES:
with translation.override(lang):
filename = path.join(options["output_dir"], f"{lang}.ts")
self._write_resolver_file(filename)
Empty file added src/ts_routes/py.typed
Empty file.
138 changes: 138 additions & 0 deletions src/ts_routes/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""
Copied and modified from django-js-routes https://github.com/ellmetha/django-js-routes
JS Routes serializers
=====================
This modules defines serializer classes responsible for extracting Django URLs that should be
exposed to the client side to a specific format (eg. JSON). The resoluting exports are likely to
be used in a Javascript module to provide reverse lookup functionality on the client side.
"""

import json
import re
from urllib.parse import urljoin

from django.conf import settings
from django.urls import get_resolver
from django.urls.resolvers import (
LocalePrefixPattern,
RegexPattern,
RoutePattern,
URLPattern,
URLResolver,
)

from .utils.text import replace


class URLPatternsSerializer:
"""The main class responsible for the serialization of the URLs exposed on the client side.
This class implements a mechanism allowing to traverse the tree of URLs of a specific URL
resolver in order to return the URLs that should be exposed on the client side in a specific
format such as JSON. The exported object provides a mapping between fully qualified (namespaced)
URL names and the corresponding paths.
This class uses the default URL resolver if no one is set at initialization time.
"""

_url_arg_re = re.compile(r"(\(.*?\))")
_url_kwarg_re = re.compile(r"(\(\?P\<(.*?)\>.*?\))")
_url_optional_char_re = re.compile(r"(?:\w|/)(?:\?|\*)")
_url_optional_group_re = re.compile(r"\(\?\:.*\)(?:\?|\*)")
_url_path_re = re.compile(r"<(.*?)>")

def __init__(self, resolver=None):
self.resolver = resolver or get_resolver()

def to_json(self):
"""Serializes the URLs to be exported in a JSON array."""
return json.dumps(dict(self._parse()))

def _get_routes_inclustion_list(self):
return getattr(settings, "TS_ROUTES_INCLUSION_LIST", []) or getattr(
settings, "JS_ROUTES_INCLUSION_LIST", []
)

def _parse(self):
return self._parse_resolver(self.resolver)

def _parse_resolver(
self, resolver, parent_namespace=None, parent_url_prefix=None, include_all=False
):
namespace = ":".join(
filter(lambda n: n, [parent_namespace, resolver.namespace])
)
include_all = include_all or (namespace in self._get_routes_inclustion_list())

urls = []
for url_pattern in resolver.url_patterns:
if isinstance(url_pattern, URLResolver):
url_prefix = urljoin(
parent_url_prefix or "/", self._prepare_url_part(url_pattern)
)
urls = urls + self._parse_resolver(
url_pattern,
parent_namespace=namespace,
parent_url_prefix=url_prefix,
include_all=include_all,
)
elif isinstance(url_pattern, URLPattern) and url_pattern.name:
url_name = ":".join(filter(lambda n: n, [namespace, url_pattern.name]))
if url_name in self._get_routes_inclustion_list() or include_all:
full_url = self._prepare_url_part(url_pattern)
urls.append((url_name, urljoin(parent_url_prefix or "/", full_url)))

return urls

def _prepare_url_part(self, url_pattern):
url = ""

if isinstance(url_pattern.pattern, RegexPattern):
url = url_pattern.pattern._regex
elif isinstance(url_pattern.pattern, RoutePattern):
url = url_pattern.pattern._route
elif isinstance(url_pattern.pattern, LocalePrefixPattern):
url = url_pattern.pattern.language_prefix
else: # pragma: no cover
raise ValueError(
'url_pattern must be a valid URL pattern ; "{}" is not'.format(
url_pattern
)
)

final_url = replace(url, [("^", ""), ("$", "")])
final_url = self._remove_optional_groups_from_url(final_url)
final_url = self._remove_optional_characters_from_url(final_url)
final_url = self._replace_arguments_in_url(final_url)

return final_url

def _remove_optional_groups_from_url(self, url):
matches = self._url_optional_group_re.findall(url)
return replace(url, [(el, "") for el in matches]) if matches else url

def _remove_optional_characters_from_url(self, url):
matches = self._url_optional_char_re.findall(url)
return replace(url, [(el, "") for el in matches]) if matches else url

def _replace_arguments_in_url(self, url):
# Identifies and replaces named URL arguments inside the URL.
kwarg_matches = self._url_kwarg_re.findall(url)
url = (
replace(url, [(el[0], "<{}>".format(el[1])) for el in kwarg_matches])
if kwarg_matches
else url
)

# Identifies and replaces unnamed URL arguments inside the URL.
args_matches = self._url_arg_re.findall(url)
url = replace(url, [(el, "<>") for el in args_matches]) if args_matches else url

return url


url_patterns_serializer = URLPatternsSerializer()
Loading

0 comments on commit 52c1518

Please sign in to comment.