diff --git a/.gitignore b/.gitignore index f9268e3..cecb533 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,6 @@ cython_debug/ # MacOS .DS_Store .AppleDouble -.LSOverride \ No newline at end of file +.LSOverride + +junit.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0c21804 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,81 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-toml + - id: check-yaml + files: \.yaml$ + - id: trailing-whitespace + exclude: (migrations/|tests/).* + - id: end-of-file-fixer + exclude: (migrations/|tests/).* + - id: check-added-large-files + exclude: (migrations/|tests/).* + - id: check-case-conflict + exclude: (migrations/|tests/).* + - id: check-merge-conflict + exclude: (migrations/|tests/).* + - id: check-docstring-first + exclude: (migrations/|tests/).* + + - repo: https://github.com/tox-dev/pyproject-fmt + rev: 2.2.1 + hooks: + - id: pyproject-fmt + + - repo: https://github.com/tox-dev/tox-ini-fmt + rev: 1.3.1 + hooks: + - id: tox-ini-fmt + + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.2 + hooks: + - id: pyupgrade + + - repo: https://github.com/pre-commit/mirrors-isort + rev: v5.10.1 + hooks: + - id: isort + exclude: (migrations/|tests/).* + + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + args: [ "--config=pyproject.toml" ] + exclude: (migrations/).* + + - repo: https://github.com/PyCQA/bandit + rev: 1.7.4 + hooks: + - id: bandit + args: [ "-c", "pyproject.toml", "-r", "." ] + additional_dependencies: [ "bandit[toml]" ] + exclude: (migrations/|tests/).* + + - repo: local + hooks: + - id: pytest + name: Pytest + entry: poetry run pytest -v + language: system + types: [ python ] + stages: [ commit ] + pass_filenames: false + always_run: true + + - id: pylint + name: pylint + entry: poetry run pylint + language: system + types: [ python ] + require_serial: true + args: + - "-rn" + - "-sn" + - "--rcfile=pyproject.toml" + - "--load-plugins=pylint_pytest" + + files: ^hybridrouter/ + exclude: (migrations/|tests/).* diff --git a/.vscode/settings.json b/.vscode/settings.json index e495a0f..34b81ce 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,4 +14,4 @@ "-s", "./hybridrouter/tests/" ] -} \ No newline at end of file +} diff --git a/README.md b/README.md index 03305d2..9c0c2ed 100644 --- a/README.md +++ b/README.md @@ -76,23 +76,23 @@ This configuration will generate URLs for both APIViews and ViewSets, and includ **HybridRouter** - `register(prefix, view, basename=None)` - + Registers an `APIView` or `ViewSet` with the specified prefix. - + - `prefix`: URL prefix for the view or viewset. - `view`: The `APIView `or `ViewSet` class. - `basename`: The base name for the view or viewset (optional). If not provided, it will be automatically generated. - `register_nested_router(prefix, router)` - + Registers a nested router under a specific prefix. - + - `prefix`: URL prefix under which the nested router will be registered. - `router`: The DRF router instance to be nested. **Attributes** - `include_intermediate_views` (default True) - + Controls whether intermediate API views are automatically created for grouped endpoints. When set to True, the router will generate intermediate views that provide a browsable API listing of all endpoints under a common prefix. **Notes** @@ -178,7 +178,7 @@ router = HybridRouter(enable_intermediate_apiviews=True) ![image](./docs/imgs/After_1.png) ![image](./docs/imgs/After_2.png) -This improves the readability and the logic of the browsable API and provides a better user experience. +This improves the readability and the logic of the browsable API and provides a better user experience. And as you can see that will not interfere with other already existing views. **Here, the `ServerConfigViewSet` is still accessible through the `coucou` endpoint and as not been overridden by an intermediary API view.** @@ -201,9 +201,9 @@ Tests automatic conflict resolution when multiple views or viewsets are register ## Notes - Compatibility - + The HybridRouter is designed to work seamlessly with Django REST Framework and is compatible with existing DRF features like schema generation. - + - Spectacular Support - - Will be added in the future. \ No newline at end of file + + Will be added in the future. diff --git a/hybridrouter/apps.py b/hybridrouter/apps.py index 46d766a..e154d80 100644 --- a/hybridrouter/apps.py +++ b/hybridrouter/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class WaitForDbConfig(AppConfig): +class HybridRouterConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "hybridrouter" diff --git a/hybridrouter/hybridrouter.py b/hybridrouter/hybridrouter.py old mode 100644 new mode 100755 index c98680f..d473da5 --- a/hybridrouter/hybridrouter.py +++ b/hybridrouter/hybridrouter.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from typing import Optional, Type, Union, overload from django.urls import include, path, re_path from django.urls.exceptions import NoReverseMatch @@ -50,12 +51,34 @@ def _add_route(self, path_parts, view, basename=None): node.basename = basename node.is_viewset = is_viewset - def register(self, prefix, view, basename=None): + @overload + def register( + self, prefix: str, viewset: Type[APIView], basename: Optional[str] = None + ) -> None: + ... + + @overload + def register( + self, prefix: str, viewset: Type[ViewSetMixin], basename: Optional[str] = None + ) -> None: + ... + + def register( + self, + prefix: str, + viewset: Union[Type[APIView], Type[ViewSetMixin]], + basename: Optional[str] = None, + ) -> None: """ - Registers a ViewSet or a view with the specified prefix. + Registers an APIView or ViewSet with the specified prefix. + + Args: + prefix (str): URL prefix for the view or viewset. + viewset (Type[APIView] or Type[ViewSetMixin]): The APIView or ViewSet class. + basename (str, optional): The base name for the view or viewset. Defaults to None. """ if basename is None: - basename = self.get_default_basename(view) + basename = self.get_default_basename(viewset) path_parts = prefix.strip("/").split("/") # Register the information for conflict resolution @@ -64,7 +87,7 @@ def register(self, prefix, view, basename=None): self.basename_registry[basename].append( { "prefix": prefix, - "view": view, + "view": viewset, "basename": basename, "path_parts": path_parts, } @@ -93,8 +116,9 @@ def _resolve_basename_conflicts(self): # Conflict detected prefixes = [reg["prefix"] for reg in registrations] logger.warning( - f"The basename '{basename}' is used for multiple registrations: {', '.join(prefixes)}. " - "Generating unique basenames." + "The basename '%s' is used for multiple registrations: %s. Generating unique basenames.", + basename, + ", ".join(prefixes), ) # Assign new unique basenames for idx, reg in enumerate(registrations, start=1): @@ -182,13 +206,6 @@ def _get_viewset_urls(self, viewset, prefix, basename): return urls - def get_routes(self, viewset): - """ - Return the list of routes for a given viewset. - """ - # We can reuse DRF's get_routes method - return super().get_routes(viewset) - def get_method_map(self, viewset, method_map): """ Given a viewset and a mapping {http_method: action}, @@ -201,15 +218,14 @@ def get_method_map(self, viewset, method_map): bound_methods[http_method] = action return bound_methods - def get_lookup_regex(self, viewset): + def get_lookup_regex(self, viewset, lookup_prefix=""): """ Return the regex pattern for the lookup field. """ lookup_field = getattr(viewset, "lookup_field", "pk") + lookup_url_kwarg = getattr(viewset, "lookup_url_kwarg", None) or lookup_field lookup_value = getattr(viewset, "lookup_value_regex", "[^/.]+") - return "(?P<{lookup_field}>{lookup_value})".format( - lookup_field=lookup_field, lookup_value=lookup_value - ) + return f"(?P<{lookup_prefix}{lookup_url_kwarg}>{lookup_value})" def _get_api_root_view(self, node, prefix): api_root_dict = OrderedDict() diff --git a/hybridrouter/tests/conftest.py b/hybridrouter/tests/conftest.py index eabb46f..403cb4c 100644 --- a/hybridrouter/tests/conftest.py +++ b/hybridrouter/tests/conftest.py @@ -1,5 +1,6 @@ import django import pytest +from django.core.exceptions import ImproperlyConfigured from django.urls import get_resolver from .utils import list_urls @@ -13,7 +14,7 @@ def recevoir_test_url_resolver(url_resolver): def pytest_exception_interact(node, call, report): - global test_url_resolver # Rendre la variable globale + global test_url_resolver print("test_url_resolver lors de l'exception:", test_url_resolver) @@ -23,25 +24,16 @@ def pytest_exception_interact(node, call, report): if test_url_resolver: all_urls = test_url_resolver else: - all_urls = get_resolver().url_patterns - - def collect_urls(urlpatterns, prefix="http://localhost/"): - urls = [] - for pattern in urlpatterns: - if hasattr(pattern, "url_patterns"): - urls.extend( - collect_urls( - pattern.url_patterns, prefix + str(pattern.pattern) - ) - ) - else: - url = prefix + str(pattern.pattern) - name = pattern.name if pattern.name else "None" - urls.append(f"{url} -> {name}") - return urls + try: + all_urls = get_resolver().url_patterns + # Votre code ici + except ImproperlyConfigured: + return + except ModuleNotFoundError as e: + print(f"Erreur lors de l'accès au résolveur : {e}") + return urls_list = list_urls(all_urls, prefix="http://localhost/") - # urls_list = collect_urls(all_urls) urls_text = "\n".join(urls_list) if hasattr(report, "longrepr"): diff --git a/hybridrouter/tests/urls.py b/hybridrouter/tests/urls.py new file mode 100644 index 0000000..637600f --- /dev/null +++ b/hybridrouter/tests/urls.py @@ -0,0 +1 @@ +urlpatterns = [] diff --git a/hybridrouter/tests/views.py b/hybridrouter/tests/views.py index 92cc784..f7a4e4e 100644 --- a/hybridrouter/tests/views.py +++ b/hybridrouter/tests/views.py @@ -3,8 +3,9 @@ from .models import Item from .serializers import ItemSerializer + class ItemView(APIView): def get(self, request): items = Item.objects.all() serializer = ItemSerializer(items, many=True) - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) diff --git a/hybridrouter/tests/viewsets.py b/hybridrouter/tests/viewsets.py index e315990..8d75a2c 100644 --- a/hybridrouter/tests/viewsets.py +++ b/hybridrouter/tests/viewsets.py @@ -2,11 +2,13 @@ from .models import Item from .serializers import ItemSerializer + class ItemViewSet(ModelViewSet): queryset = Item.objects.all() serializer_class = ItemSerializer - + + class SlugItemViewSet(ModelViewSet): queryset = Item.objects.all() serializer_class = ItemSerializer - lookup_field = 'name' \ No newline at end of file + lookup_field = "name" diff --git a/hybridrouter/utils.py b/hybridrouter/utils.py index c6c5ed0..a3c3527 100644 --- a/hybridrouter/utils.py +++ b/hybridrouter/utils.py @@ -1,12 +1,13 @@ import logging + class ColorFormatter(logging.Formatter): COLOR_MAP = { - 'ERROR': '\033[31m', # Rouge - 'WARNING': '\033[33m', # Jaune/Orange - 'INFO': '\033[32m', # Vert + "ERROR": "\033[31m", # Rouge + "WARNING": "\033[33m", # Jaune/Orange + "INFO": "\033[32m", # Vert } - RESET = '\033[0m' + RESET = "\033[0m" def __init__(self, fmt=None, datefmt=None): super().__init__(fmt, datefmt) @@ -24,13 +25,14 @@ def format(self, record): # Combiner la date non colorée avec le message coloré return f"{date_str}{colored_message}" + # Configurer le logger 'hybridrouter' -logger = logging.getLogger('hybridrouter') +logger = logging.getLogger("hybridrouter") logger.setLevel(logging.DEBUG) # Définir le niveau de log souhaité # Définir le format avec la date -log_format = '[%(asctime)s] %(levelname)s: %(message)s' -date_format = '%d/%b/%Y %H:%M:%S' +log_format = "[%(asctime)s] %(levelname)s: %(message)s" +date_format = "%d/%b/%Y %H:%M:%S" # Initialiser le ColorFormatter avec le format et le format de date color_formatter = ColorFormatter(fmt=log_format, datefmt=date_format) @@ -40,4 +42,4 @@ def format(self, record): handler.setFormatter(color_formatter) # Ajouter le handler au logger -logger.addHandler(handler) \ No newline at end of file +logger.addHandler(handler) diff --git a/pyproject.toml b/pyproject.toml index 3e24320..dad5133 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ "poetry-core" ] [tool.poetry] name = "djangorestframework-hybridrouter" version = "1.0.0" -description = "Django app that provide an hybrid router for Django Rest Framework" +description = "Django app that provides a hybrid router for Django Rest Framework" authors = [ "enzo_frnt" ] license = "MIT" readme = "README.md" @@ -15,6 +15,9 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", @@ -24,7 +27,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -42,12 +44,9 @@ packages = [ "Issues" = "https://github.com/enzofrnt/djangorestframework-hybridrouter/issues" [tool.poetry.dependencies] -tox = "^4.16.0" -python = ">=3.8,<4.0" -django = [ - { version = ">=4.2,<5.0", python = ">=3.8,<3.10" }, - { version = ">=4.2,<5.3", python = ">=3.10" }, # Django 4.2 and 5.x for Python 3.10+ -] +python = ">=3.8" +django = ">=3.2,<5.2" +djangorestframework = ">=3.12.4,<3.16" [tool.poetry.group.dev.dependencies] pytest = "8.2.0" @@ -157,7 +156,7 @@ class-rgx = "[A-Z_][a-zA-Z0-9]+$" module-rgx = "(([a-z_][a-z0-9_]*)|(__.*__))$" [tool.bandit] -targets = [ "./wait_for_db" ] +targets = [ "./hybridrouter" ] exclude_dirs = [ "tests", "migrations", diff --git a/setup.py b/setup.py deleted file mode 100644 index 2800b23..0000000 --- a/setup.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -This model is used to create the package -""" - -from setuptools import setup - - -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() - -setup( - name='djangorestframework-hybridrouter', - version='0.1.5', - packages=[ - 'hybridrouter', - ], - install_requires=[ - 'Django', - 'djangorestframework', - ], - description='A package to regsiter viewsets and views in the same router', - long_description=long_description, - long_description_content_type="text/markdown", - author='Made by enzo_frnt from a ', - url='https://github.com/enzofrnt/djangorestframework-hybridrouter', - keywords=["Django", "Django REST Framework", "viewsets", "views", "router", "view", "viewset"], - test_suite='test', -) diff --git a/tox.ini b/tox.ini index 506246a..ba5de23 100644 --- a/tox.ini +++ b/tox.ini @@ -2,33 +2,32 @@ requires = tox>=4.2 env_list = - py312-django428 - py312-django50 - py312-django51 - py311-django413 - py311-django42 - py311-django50 - py311-django51 - py310-django329 - py310-django40 - py310-django41 - py310-django42 - py310-django50 - py310-django51 - py39-django32 - py39-django40 - py39-django41 - py39-django42 + py312-django4.2-drf3.14 + py312-django5.0-drf3.14 + py312-django5.1-drf3.15 + py311-django4.1-drf3.14 + py311-django4.2-drf3.14 + py311-django5.0-drf3.14 + py39-django3.2-drf3.12 + py39-django4.0-drf3.14 + py39-django4.1-drf3.14 [testenv] package = editable deps = codecov + django3.2: Django>=3.2,<4.0 + django4.0: Django>=4.0,<4.1 + django4.1: Django>=4.1,<4.2 + django4.2: Django>=4.2,<4.3 + django5.0: Django>=5.0,<5.1 + django5.1: Django>=5.1,<5.2 + drf3.12: djangorestframework>=3.12.4,<3.13 + drf3.14: djangorestframework>=3.14,<3.15 + drf3.15: djangorestframework>=3.15,<3.16 pytest pytest-cov pytest-django - django40: Django<4.1,>=4 - django50: Django<5.1,>=5 commands = pytest --cov=hybridrouter --cov-report=html --cov-report=xml --junitxml=junit.xml -o junit_family=legacy codecov -f coverage.xml