django-small-view-set 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 nateonguitar
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.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.1
2
+ Name: django-small-view-set
3
+ Version: 0.1.0
4
+ Summary: A lightweight Django ViewSet alternative with minimal abstraction.
5
+ Home-page: https://github.com/yourusername/django-small-view-set
6
+ License: MIT
7
+ Keywords: django,viewset
8
+ Author: Nate Brooks
9
+ Requires-Python: >=3.8
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Requires-Dist: django (>=3.2)
17
+ Project-URL: Repository, https://github.com/yourusername/django-small-view-set
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Django Small View Set
21
+
22
+ A lightweight Django ViewSet alternative with minimal abstraction. This library provides a simple and transparent way to define API endpoints without relying on complex abstractions.
23
+
24
+ ## Documentation
25
+
26
+ - [Getting Started](./README_SIMPLE.md): A bare-bones example to get the fundamentals.
27
+ - [Register URLs](./README_REGISTER_URLS): How to register viewset urls.
28
+ - [Custom Endpoints](./README_CUSTOM_ENDPOINT.md): Learn how to define custom endpoints alongside the default router.
29
+ - [Handling Endpoint Exceptions](./README_HANDLE_ENDPOINT_EXCEPTIONS.md): Understand how to write your own decorators for exception handling.
30
+ - [Custom Protections](./README_CUSTOM_PROTECTIONS.md): Learn how to subclass `SmallViewSet` to add custom protections like logged-in checks.
31
+ - [DRF Compatibility](./README_DRF_COMPATIBILITY.md): Learn how to use some of Django Rest Framework's tools, like Serializers.
32
+
33
+ ## Reasoning behind this library:
34
+
35
+ A note from the library creator:
36
+
37
+ I feel like a justification to not "just use DRF" is in order.
38
+
39
+ After working in Django Rest Framework for years, I liked how nice it was for getting an API up and running really quickly, but I hated how often I would get stuck debugging some abstracted thing in a serializer, or a viewset, then spend too much time implementing some niche (and usually hacky feeling) fix.
40
+
41
+ Sadly, there are a lot of these pain points in DRF, and a lack of separation-of-concerns. For instance, I always disliked how serializers don't just serialize, they do operations like saving and updating, often resulting in business logic in serializers. That is not serialization. When using mixins on DRF viewsets, often customization methods are required, like which permissions to use for each endpoint, which serializer to use, add a custom "perform_create" method to help serializers save, etc. and it makes it difficult to do reverse lookups to endpoints. It also meant POSTing to reverse('foo-list') would happen, which really should be posting to the "foo-collection" or something else. It's nitpicky yes, but these nitpicks are all over.
42
+
43
+ So I wrote this. No black box anything. Surprisingly (because it was not the goal), I reduced the number of lines of code in every single one of my viewsets because I didn't need to register so many mixins, set up complex decorators, or define so many helper methods.
44
+
45
+ I still like some tools DRF provides, like throttles and serializers (when only used as validators or model => json conversions), those are still completely compatible and honestly amazing. I'm glad DRF exists, I've gotten a TON of use out of it over the years.
46
+
47
+ I also really liked my Spring Boot experience (and other frameworks, but Spring Boot was recent) where the pattern is to throw exceptions and a global exception handler will convert the errors to json responses, allowing a developer to bail on an endpoint at any time, no matter how deep in business logic they may be.
48
+
49
+ With this ViewSet and pattern, your call stack will never be far away from the real issue, letting you debug in peace.
@@ -0,0 +1,9 @@
1
+ small_view_set/README.md,sha256=ltlV_52FkT8L1V_JfczYmSMRKaqOWWFKpcaWmzBnec4,1121
2
+ small_view_set/__init__.py,sha256=FoYcTj03W41-w0jC1EOv1_VD4iBxO_03fIr_plSKnp8,441
3
+ small_view_set/decorators.py,sha256=OSwGz5SfTw7hUjOcPNmCmUl8mLqaHOMXaLcqg9JBHXo,6750
4
+ small_view_set/exceptions.py,sha256=8CAnzvNZFlqjez8z66jLo-4P3Tlj5Y5MioDPmrb9wQg,948
5
+ small_view_set/small_view_set.py,sha256=THDeJp1PmodaWzFUUnowpDmhH8w1El4p97jvQnjyU4k,5192
6
+ django_small_view_set-0.1.0.dist-info/LICENSE,sha256=M4ZuHeiGHHuewaZyqz7tol4E6E2GMz7fF1ywNoXD1tA,1069
7
+ django_small_view_set-0.1.0.dist-info/METADATA,sha256=xAvDG-F2RRm2ByiAe8No_qcuuBeYwJ0PRIceH2fPB4o,3819
8
+ django_small_view_set-0.1.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
9
+ django_small_view_set-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.7.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,40 @@
1
+ # Steps for Releasing to PyPI
2
+
3
+ 1. **Build the Docker Image**:
4
+ ```bash
5
+ docker compose build --no-cache
6
+ docker compose up -d
7
+ ```
8
+
9
+ 2. **Configure PyPI Credentials**:
10
+ - Set up your PyPI token inside the Docker container:
11
+ ```bash
12
+ docker compose run django-small-view-set-builder sh -c 'poetry config pypi-token.pypi $POETRY_PUBLISH_TOKEN'
13
+ ```
14
+
15
+ 3. **Install Dependencies**:
16
+ - Install dependencies inside the container after mounting the directory:
17
+ ```bash
18
+ docker compose run django-small-view-set-builder poetry install
19
+ ```
20
+
21
+ 4. **Run Tests**:
22
+ - Run tests before releasing:
23
+ ```bash
24
+ docker compose run django-small-view-set-builder python /app/tests/manage.py test
25
+ ```
26
+
27
+ 5. **Build the Package**:
28
+ Inside the Docker container, build the package:
29
+ ```bash
30
+ docker compose run django-small-view-set-builder poetry build
31
+ ```
32
+
33
+ 6. **Publish to PyPI**:
34
+ Upload the package to PyPI:
35
+ ```bash
36
+ docker compose run django-small-view-set-builder poetry publish
37
+ ```
38
+
39
+ 7. **Verify the Release**:
40
+ - Visit your package page on PyPI to confirm the release.
@@ -0,0 +1,21 @@
1
+ from .small_view_set import SmallViewSet
2
+ from .decorators import (
3
+ default_handle_endpoint_exceptions,
4
+ disable_endpoint,
5
+ )
6
+ from .exceptions import (
7
+ BadRequest,
8
+ EndpointDisabledException,
9
+ MethodNotAllowed,
10
+ Unauthorized,
11
+ )
12
+
13
+ __all__ = [
14
+ "SmallViewSet",
15
+ "default_handle_endpoint_exceptions",
16
+ "disable_endpoint",
17
+ "BadRequest",
18
+ "EndpointDisabledException",
19
+ "MethodNotAllowed",
20
+ "Unauthorized",
21
+ ]
@@ -0,0 +1,185 @@
1
+ import logging
2
+ from django.conf import settings
3
+ from functools import wraps
4
+
5
+ import json
6
+ import functools
7
+ from django.conf import settings
8
+ from django.core.exceptions import ObjectDoesNotExist, SuspiciousOperation, PermissionDenied
9
+ from django.http import (
10
+ Http404,
11
+ JsonResponse,
12
+ )
13
+
14
+ from .exceptions import EndpointDisabledException, MethodNotAllowed, Unauthorized
15
+
16
+
17
+ def _get_logger(name):
18
+ """
19
+ Retrieves a logger by name. If no logger is configured in settings.LOGGING,
20
+ it provides a fallback logger with a StreamHandler.
21
+
22
+ To control this logger in `settings.py`, add a logger configuration
23
+ under the `LOGGING` dictionary. For example:
24
+
25
+ LOGGING = {
26
+ 'version': 1,
27
+ 'disable_existing_loggers': False,
28
+ 'handlers': {
29
+ 'console': {
30
+ 'class': 'logging.StreamHandler',
31
+ },
32
+ },
33
+ 'loggers': {
34
+ 'django-small-view-set.default_handle_endpoint_exceptions': {
35
+ 'handlers': ['console'],
36
+ 'level': 'INFO',
37
+ 'propagate': True,
38
+ },
39
+ },
40
+ }
41
+
42
+ This configuration ensures that the logger named
43
+ 'django-small-view-set.default_handle_endpoint_exceptions' uses the specified
44
+ handlers and logging level.
45
+ """
46
+ logger = logging.getLogger(name)
47
+
48
+ # Check if Django's logging is configured
49
+ if not logger.hasHandlers():
50
+ # Fallback logger setup
51
+ handler = logging.StreamHandler()
52
+ formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
53
+ handler.setFormatter(formatter)
54
+ logger.addHandler(handler)
55
+ logger.setLevel(logging.INFO)
56
+
57
+ return logger
58
+
59
+
60
+ def default_handle_endpoint_exceptions(func):
61
+ @functools.wraps(func)
62
+ def wrapper(*args, **kwargs):
63
+ logger = _get_logger('django-small-view-set.default_handle_endpoint_exceptions')
64
+
65
+ try:
66
+ return func(*args, **kwargs)
67
+
68
+ except json.JSONDecodeError:
69
+ return JsonResponse(data={"errors": "Invalid JSON"}, status=400)
70
+
71
+ except (TypeError, ValueError) as e:
72
+ if hasattr(e, 'detail'):
73
+ return JsonResponse(data={'errors': e.detail}, status=400)
74
+ if hasattr(e, 'message'):
75
+ return JsonResponse(data={'errors': e.message}, status=400)
76
+ return JsonResponse(data=None, safe=False, status=400)
77
+
78
+ except Unauthorized:
79
+ return JsonResponse(data=None, safe=False, status=401)
80
+
81
+ except (PermissionDenied, SuspiciousOperation):
82
+ return JsonResponse(data=None, safe=False, status=403)
83
+
84
+ except (EndpointDisabledException, Http404, ObjectDoesNotExist):
85
+ return JsonResponse(data=None, safe=False, status=404)
86
+
87
+ except MethodNotAllowed as e:
88
+ return JsonResponse(
89
+ data={'errors': f"Method {e.method} is not allowed"},
90
+ status=405)
91
+
92
+ except Exception as e:
93
+ # Catch-all exception handler for API endpoints.
94
+ #
95
+ # - Always defaults to HTTP 500 with "Internal server error" unless the exception provides a more specific status code and error details.
96
+ # - Duck types to extract error information from `detail` or `message` attributes, if available.
97
+ # - Never exposes internal exception contents to end users for 5xx server errors unless settings.DEBUG is True.
98
+ # - Allows structured error payloads (string, list, or dict) without assumptions about the error format.
99
+ # - Logs exceptions fully for server-side diagnostics, distinguishing handled vs unhandled cases.
100
+ #
101
+ # This design prioritizes API security, developer debugging, and future portability across projects.
102
+
103
+ status_code = getattr(e, 'status_code', 500)
104
+ error_contents = None
105
+
106
+ if hasattr(e, 'detail'):
107
+ error_contents = e.detail
108
+ elif hasattr(e, 'message') and isinstance(e.message, str):
109
+ error_contents = e.message
110
+
111
+ if 400 <= status_code <= 499:
112
+ if status_code == 400:
113
+ message = 'Bad request'
114
+ elif status_code == 401:
115
+ message = 'Unauthorized'
116
+ elif status_code == 403:
117
+ message = 'Forbidden'
118
+ elif status_code == 404:
119
+ message = 'Not found'
120
+ elif status_code == 405:
121
+ message = 'Method not allowed'
122
+ elif status_code == 429:
123
+ message = 'Too many requests'
124
+ elif error_contents:
125
+ message = error_contents
126
+ else:
127
+ message = 'An error occurred'
128
+
129
+ if settings.DEBUG and error_contents:
130
+ message = error_contents
131
+ else:
132
+ status_code = 500
133
+ message = 'Internal server error'
134
+ if settings.DEBUG:
135
+ message = error_contents if error_contents else str(e)
136
+
137
+ func_name = func.__name__
138
+ e_name = type(e).__name__
139
+ if error_contents:
140
+ msg = f"Handled API exception in {func_name}: {e_name}: {error_contents}"
141
+ logger.error(msg)
142
+
143
+ else:
144
+ msg = f"Unhandled exception in {func_name}: {e_name}: {e}"
145
+ logger.error(msg)
146
+
147
+
148
+ return JsonResponse(
149
+ data={'errors': message},
150
+ safe=False,
151
+ status=status_code,
152
+ content_type='application/json')
153
+
154
+ return wrapper
155
+
156
+
157
+ def disable_endpoint(view_func):
158
+ """
159
+ Temporarily disables an API endpoint based on the SMALL_VIEWSET_RESPECT_DISABLED_ENDPOINTS setting.
160
+
161
+ When `SMALL_VIEWSET_RESPECT_DISABLED_ENDPOINTS` in Django settings is set to `True`, this decorator
162
+ will raise an `EndpointDisabledException`, resulting in a 404 response. When set to `False`,
163
+ the endpoint will remain active, which is useful for testing environments.
164
+
165
+ Usage:
166
+ - Apply this decorator directly to a view method or action.
167
+ - Example:
168
+
169
+ ```python
170
+ class ExampleViewSet(SmallViewSet):
171
+
172
+ @disable_endpoint
173
+ @default_handle_endpoint_exceptions
174
+ def retrieve(self, request: Request) -> JsonResponse:
175
+ self.protect_retrieve(request)
176
+ . . .
177
+ ```
178
+ """
179
+ @wraps(view_func)
180
+ def wrapper(*args, **kwargs):
181
+ if settings.SMALL_VIEWSET_RESPECT_DISABLED_ENDPOINTS:
182
+ raise EndpointDisabledException()
183
+ return view_func(*args, **kwargs)
184
+ return wrapper
185
+
@@ -0,0 +1,30 @@
1
+ class EndpointDisabledException(Exception):
2
+ status_code = 404
3
+ message = "Not Found"
4
+ error_code = "not_found"
5
+
6
+ class Unauthorized(Exception):
7
+ status_code = 401
8
+ message = "Unauthorized"
9
+ error_code = "unauthorized"
10
+
11
+ class BadRequest(Exception):
12
+ status_code = 400
13
+ error_code = "bad_request"
14
+ def __init__(self, message: str | list | dict):
15
+ """
16
+ Args:
17
+ message (str | list | dict):
18
+ A JSON-serializable error description to send to the client.
19
+ Typically a string, list of errors, or dictionary of field errors.
20
+ """
21
+ self.message = message or "Bad Request"
22
+ super().__init__(message)
23
+
24
+ class MethodNotAllowed(Exception):
25
+ status_code = 405
26
+ error_code = "method_not_allowed"
27
+ def __init__(self, method: str):
28
+ self.method = method
29
+ self.message = f'Method {method} not allowed'
30
+ super().__init__(self.message)
@@ -0,0 +1,138 @@
1
+ import json
2
+ import logging
3
+ from django.http import JsonResponse
4
+ from urllib.request import Request
5
+
6
+ from .exceptions import BadRequest, MethodNotAllowed
7
+
8
+ logger = logging.getLogger('app')
9
+
10
+ class SmallViewSet:
11
+ def create(self, request: Request, *args, **kwargs):
12
+ raise MethodNotAllowed('POST')
13
+
14
+ def list(self, request: Request, *args, **kwarg):
15
+ raise MethodNotAllowed('GET')
16
+
17
+ def retrieve(self, request: Request, pk, *args, **kwargs):
18
+ raise MethodNotAllowed('GET')
19
+
20
+ def put(self, request: Request, pk, *args, **kwargs):
21
+ raise MethodNotAllowed('PUT')
22
+
23
+ def patch(self, request: Request, pk, *args, **kwargs):
24
+ raise MethodNotAllowed('PATCH')
25
+
26
+ def delete(self, request: Request, pk, *args, **kwargs):
27
+ raise MethodNotAllowed('DELETE')
28
+
29
+ def parse_json_body(self, request: Request):
30
+ if request.content_type != 'application/json':
31
+ raise BadRequest('Invalid content type')
32
+ return json.loads(request.body)
33
+
34
+ def protect_create(self, request: Request):
35
+ """
36
+ Ensures that the request method is POST.
37
+ Raises:
38
+ MethodNotAllowed: If the request method is not GET.
39
+ """
40
+ if request.method != 'POST':
41
+ raise MethodNotAllowed('POST')
42
+
43
+ def protect_list(self, request: Request):
44
+ """
45
+ Ensures that the request method is GET.
46
+ Raises:
47
+ MethodNotAllowed: If the request method is not GET.
48
+ """
49
+ if request.method != 'GET':
50
+ raise MethodNotAllowed(request.method)
51
+
52
+ def protect_retrieve(self, request: Request):
53
+ """
54
+ Ensures that the request method is GET.
55
+ Raises:
56
+ MethodNotAllowed: If the request method is not GET.
57
+ """
58
+ if request.method != 'GET':
59
+ raise MethodNotAllowed(request.method)
60
+
61
+ def protect_update(self, request: Request):
62
+ """
63
+ Ensures that the request method is PUT or PATCH.
64
+ Raises:
65
+ MethodNotAllowed: If the request method is not PUT or PATCH.
66
+ """
67
+ if request.method not in ['PUT', 'PATCH']:
68
+ raise MethodNotAllowed(request.method)
69
+
70
+ def protect_delete(self, request: Request):
71
+ """
72
+ Ensures that the request method is DELETE.
73
+ Raises:
74
+ MethodNotAllowed: If the request method is not DELETE.
75
+ """
76
+ if request.method != 'DELETE':
77
+ raise MethodNotAllowed(request.method)
78
+
79
+ def default_router(self, request: Request, pk=None, *args, **kwargs):
80
+ """
81
+ This method routes requests to the appropriate method based on the HTTP method and presence of a primary key (pk).
82
+
83
+ It also handles errors and returns appropriate JSON responses by using the decorator @default_handle_endpoint_exceptions.
84
+
85
+ GET/POST for collection endpoints and GET/PUT/PATCH/DELETE for detail endpoints.
86
+
87
+ Example:
88
+ ```
89
+ # Note: AppViewSet is a subclass of SmallViewSet with overridden protect methods with more specific logic.
90
+
91
+ class CommentViewSet(AppViewSet):
92
+ def urlpatterns(self):
93
+ return [
94
+ path('api/comments/', self.default_router, name='comments_collection'),
95
+ path('api/comments/<int:pk>/', self.default_router, name='comments_detail'),
96
+ path('api/comments/<int:pk>/custom_put/', self.custom_put, name='comments_custom_put_detail'),
97
+ ]
98
+
99
+ @default_handle_endpoint_exceptions
100
+ def create(self, request: Request):
101
+ self.protect_create(request)
102
+ . . .
103
+
104
+ @default_handle_endpoint_exceptions
105
+ def update(self, request: Request, pk: int):
106
+ self.protect_update(request)
107
+ . . .
108
+
109
+ @default_handle_endpoint_exceptions
110
+ def custom_put(self, request: Request, pk: int):
111
+ self.protect_update(request)
112
+ . . .
113
+
114
+ @disable_endpoint
115
+ @default_handle_endpoint_exceptions
116
+ def some_disabled_endpoint(self, request: Request):
117
+ self.protect_retrieve(request)
118
+ . . .
119
+
120
+ ```
121
+ """
122
+ if pk is None:
123
+ if request.method == 'GET':
124
+ return self.list(request, *args, **kwargs)
125
+ elif request.method == 'POST':
126
+ return self.create(request, *args, **kwargs)
127
+ else:
128
+ if request.method == 'GET':
129
+ return self.retrieve(request, pk, *args, **kwargs)
130
+ elif request.method == 'PUT':
131
+ return self.put(request, pk, *args, **kwargs)
132
+ elif request.method == 'PATCH':
133
+ return self.patch(request, pk, *args, **kwargs)
134
+ elif request.method == 'DELETE':
135
+ return self.delete(request, pk, *args, **kwargs)
136
+ endpoint_type = "detail" if pk else "collection"
137
+ logger.error(f'Got a none response from request_router for {endpoint_type} method {request.method}')
138
+ return JsonResponse(data=None, safe=False, status=500)