plain.api 0.1.0__tar.gz

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,13 @@
1
+ .venv
2
+ .env
3
+ *.egg-info
4
+ *.py[co]
5
+ __pycache__
6
+ *.DS_Store
7
+ .coverage
8
+
9
+ # Build files from publish
10
+ plain*/dist/
11
+
12
+ # Test apps
13
+ plain*/tests/.plain
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Dropseed, LLC
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.api
3
+ Version: 0.1.0
4
+ Summary: API for Plain.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: plain<1.0.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ <!-- This file is compiled from plain-api/plain/api/README.md. Do not edit this file directly. -->
12
+
13
+ # plain.api
@@ -0,0 +1,3 @@
1
+ <!-- This file is compiled from plain-api/plain/api/README.md. Do not edit this file directly. -->
2
+
3
+ # plain.api
@@ -0,0 +1 @@
1
+ # plain.api
File without changes
@@ -0,0 +1,6 @@
1
+ from plain.packages import PackageConfig
2
+
3
+
4
+ class Config(PackageConfig):
5
+ name = "plain.api"
6
+ label = "plainapi" # Primarily for migrations
@@ -0,0 +1,25 @@
1
+ # from bolt.exceptions import ValidationError
2
+
3
+ # class APIFormMixin:
4
+ # def clean(self):
5
+ # cleaned_data = super().clean()
6
+
7
+ # # Make sure all the field names are present in the input data
8
+ # for name, field in self.fields.items():
9
+ # if name not in self.data:
10
+ # raise ValidationError(f"Missing field {name}")
11
+
12
+ # return cleaned_data
13
+
14
+
15
+ class APIPartialFormMixin:
16
+ def __init__(self, *args, **kwargs):
17
+ super().__init__(*args, **kwargs)
18
+
19
+ # If any field is not present in the JSON input,
20
+ # then act as if it's "disabled" so Bolt
21
+ # will keep the initial value instead of setting it to the default.
22
+ # This is required because stuff like checkbox doesn't submit in HTML form data when false.
23
+ for name, field in self.fields.items():
24
+ if name not in self.data:
25
+ field.disabled = True
@@ -0,0 +1,38 @@
1
+ # Generated by Plain 0.17.0 on 2025-01-22 20:02
2
+
3
+ import uuid
4
+
5
+ import plain.api.models
6
+ from plain import models
7
+ from plain.models import migrations
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+ initial = True
12
+
13
+ dependencies = []
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="APIKey",
18
+ fields=[
19
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
20
+ (
21
+ "uuid",
22
+ models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
23
+ ),
24
+ ("created_at", models.DateTimeField(auto_now_add=True)),
25
+ ("updated_at", models.DateTimeField(auto_now=True)),
26
+ ("last_used_at", models.DateTimeField(blank=True, null=True)),
27
+ ("name", models.CharField(blank=True, max_length=255)),
28
+ (
29
+ "token",
30
+ models.CharField(
31
+ default=plain.api.models.generate_token,
32
+ max_length=40,
33
+ unique=True,
34
+ ),
35
+ ),
36
+ ],
37
+ ),
38
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Plain 0.17.0 on 2025-01-22 21:02
2
+
3
+ from plain import models
4
+ from plain.models import migrations
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ("plainapi", "0001_initial"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name="apikey",
15
+ name="expires_at",
16
+ field=models.DateTimeField(blank=True, null=True),
17
+ ),
18
+ ]
File without changes
@@ -0,0 +1,33 @@
1
+ import binascii
2
+ import os
3
+ import uuid
4
+
5
+ from plain import models
6
+
7
+
8
+ def generate_token():
9
+ return binascii.hexlify(os.urandom(20)).decode()
10
+
11
+
12
+ class APIKey(models.Model):
13
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
14
+ created_at = models.DateTimeField(auto_now_add=True)
15
+ updated_at = models.DateTimeField(auto_now=True)
16
+ expires_at = models.DateTimeField(blank=True, null=True)
17
+ last_used_at = models.DateTimeField(blank=True, null=True)
18
+
19
+ name = models.CharField(max_length=255, blank=True)
20
+
21
+ token = models.CharField(max_length=40, default=generate_token, unique=True)
22
+
23
+ # Connect to a user, for example, from your own model:
24
+ # api_key = models.OneToOneField(
25
+ # APIKey,
26
+ # on_delete=models.CASCADE,
27
+ # related_name="user",
28
+ # null=True,
29
+ # blank=True,
30
+ # )
31
+
32
+ def __str__(self):
33
+ return self.name or str(self.uuid)
@@ -0,0 +1,375 @@
1
+ from typing import Any
2
+ from uuid import UUID
3
+
4
+ from plain.forms import fields
5
+ from plain.urls import get_resolver
6
+ from plain.urls.converters import get_converters
7
+ from plain.views import View
8
+
9
+ from .responses import JsonResponse, JsonResponseCreated, JsonResponseList
10
+ from .views import APIBaseView
11
+
12
+
13
+ class OpenAPISchemaView(View):
14
+ openapi_title: str
15
+ openapi_version: str
16
+ openapi_urlconf: str
17
+
18
+ def get(self):
19
+ # TODO can heaviliy cache this - browser headers? or cache the schema obj?
20
+ return JsonResponse(
21
+ OpenAPISchema(
22
+ title=self.openapi_title,
23
+ version=self.openapi_version,
24
+ urlconf=self.openapi_urlconf,
25
+ ),
26
+ json_dumps_params={"sort_keys": True},
27
+ )
28
+
29
+
30
+ class OpenAPISchema(dict):
31
+ def __init__(self, *, title: str, version: str, urlconf="app.api.urls"):
32
+ self.urlconf = urlconf
33
+ self.url_converters = {
34
+ class_instance.__class__: key
35
+ for key, class_instance in get_converters().items()
36
+ }
37
+ paths = self.get_paths()
38
+ super().__init__(
39
+ openapi="3.0.0",
40
+ info={
41
+ "title": title,
42
+ "version": version,
43
+ # **moreinfo, or info is a dict?
44
+ },
45
+ paths=paths,
46
+ # "404": {
47
+ # "$ref": "#/components/responses/not_found"
48
+ # },
49
+ # "422": {
50
+ # "$ref": "#/components/responses/validation_failed_simple"
51
+ # }
52
+ )
53
+
54
+ # def extract_components(self, paths):
55
+ # """Look through the paths and find and repeated definitions
56
+ # that can be pulled out as components."""
57
+ # from collections import Counter
58
+ # components = Counter()
59
+ # for path in paths.values():
60
+
61
+ def get_paths(self) -> dict[str, dict[str, Any]]:
62
+ resolver = get_resolver(self.urlconf)
63
+ paths = {}
64
+
65
+ for url_pattern in resolver.url_patterns:
66
+ for pattern, root_path in self.url_patterns_from_url_pattern(
67
+ url_pattern, "/"
68
+ ):
69
+ path = self.path_from_url_pattern(pattern, root_path)
70
+ if operations := self.operations_from_url_pattern(pattern):
71
+ paths[path] = operations
72
+ if parameters := self.parameters_from_url_patterns(
73
+ [url_pattern, pattern]
74
+ ):
75
+ # Assume all methods have the same parameters for now (path params)
76
+ for method in operations:
77
+ operations[method]["parameters"] = parameters
78
+
79
+ return paths
80
+
81
+ def url_patterns_from_url_pattern(self, url_pattern, root_path) -> list:
82
+ if hasattr(url_pattern, "url_patterns"):
83
+ include_path = self.path_from_url_pattern(url_pattern, root_path)
84
+ url_patterns = []
85
+ for u in url_pattern.url_patterns:
86
+ url_patterns.extend(self.url_patterns_from_url_pattern(u, include_path))
87
+ return url_patterns
88
+ else:
89
+ return [(url_pattern, root_path)]
90
+
91
+ def path_from_url_pattern(self, url_pattern, root_path) -> str:
92
+ path = root_path + str(url_pattern.pattern)
93
+
94
+ for name, converter in url_pattern.pattern.converters.items():
95
+ key = self.url_converters[converter.__class__]
96
+ path = path.replace(f"<{key}:{name}>", f"{{{name}}}")
97
+ return path
98
+
99
+ def parameters_from_url_patterns(self, url_patterns) -> list[dict[str, Any]]:
100
+ """Need to process any parent/included url patterns too"""
101
+ parameters = []
102
+
103
+ for url_pattern in url_patterns:
104
+ for name, converter in url_pattern.pattern.converters.items():
105
+ parameters.append(
106
+ {
107
+ "name": name,
108
+ "in": "path",
109
+ "required": True,
110
+ "schema": {
111
+ "type": "string",
112
+ "pattern": converter.regex,
113
+ # "format": "uuid",
114
+ },
115
+ }
116
+ )
117
+
118
+ return parameters
119
+
120
+ def operations_from_url_pattern(self, url_pattern) -> dict[str, Any]:
121
+ view_class = url_pattern.callback.view_class
122
+
123
+ if not issubclass(view_class, APIBaseView):
124
+ return {}
125
+
126
+ operations = {}
127
+
128
+ known_http_method_names = [
129
+ "get",
130
+ "post",
131
+ "put",
132
+ "patch",
133
+ "delete",
134
+ # Don't care about these ones...
135
+ # "head",
136
+ # "options",
137
+ # "trace",
138
+ ]
139
+
140
+ for method in known_http_method_names:
141
+ if not hasattr(view_class, method):
142
+ continue
143
+
144
+ if responses := self.responses_from_class_method(view_class, method):
145
+ operations[method] = {
146
+ "responses": responses,
147
+ }
148
+
149
+ if parameters := self.request_body_from_class_method(view_class, method):
150
+ operations[method]["requestBody"] = parameters
151
+
152
+ return operations
153
+
154
+ def request_body_from_class_method(self, view_class, method: str) -> dict:
155
+ """Gets parameters from the form_class on a view"""
156
+
157
+ if method not in ("post", "put", "patch"):
158
+ return {}
159
+
160
+ form_class = view_class.form_class
161
+ if not form_class:
162
+ return {}
163
+
164
+ parameters = []
165
+ # Any args or kwargs in form.__init__ need to be optional
166
+ # for this to work...
167
+ for name, field in form_class().fields.items():
168
+ parameters.append(
169
+ {
170
+ "name": name,
171
+ # "in": "query",
172
+ # "required": field.required,
173
+ "schema": self.type_to_schema_obj(field),
174
+ }
175
+ )
176
+
177
+ return {
178
+ "content": {
179
+ "application/json": {
180
+ "schema": {
181
+ "type": "object",
182
+ "properties": {p["name"]: p["schema"] for p in parameters},
183
+ }
184
+ },
185
+ },
186
+ }
187
+
188
+ def responses_from_class_method(
189
+ self, view_class, method: str
190
+ ) -> dict[str, dict[str, Any]]:
191
+ class_method = getattr(view_class, method)
192
+ return_type = class_method.__annotations__["return"]
193
+
194
+ if hasattr(return_type, "status_code"):
195
+ return_types = [return_type]
196
+ else:
197
+ # Assume union...
198
+ return_types = return_type.__args__
199
+
200
+ responses: dict[str, dict[str, Any]] = {}
201
+
202
+ for return_type in return_types:
203
+ if return_type is JsonResponse or return_type is JsonResponseCreated:
204
+ schema = self.type_to_schema_obj(
205
+ view_class.object_to_dict.__annotations__["return"]
206
+ )
207
+
208
+ content = {"application/json": {"schema": schema}}
209
+ elif return_type is JsonResponseList:
210
+ schema = self.type_to_schema_obj(
211
+ view_class.object_to_dict.__annotations__["return"]
212
+ )
213
+
214
+ content = {
215
+ "application/json": {
216
+ "schema": {
217
+ "type": "array",
218
+ "items": schema,
219
+ }
220
+ }
221
+ }
222
+ else:
223
+ content = None
224
+
225
+ response_key = str(return_type.status_code)
226
+ responses[response_key] = {}
227
+
228
+ if description := getattr(return_type, "openapi_description", ""):
229
+ responses[response_key]["description"] = description
230
+
231
+ responses["5XX"] = {
232
+ "description": "Server error",
233
+ }
234
+
235
+ if content:
236
+ responses[response_key]["content"] = content
237
+
238
+ return responses
239
+
240
+ def type_to_schema_obj(self, t) -> dict[str, Any]:
241
+ # if it's a union with None, add nullable: true
242
+
243
+ # if t has a comment, add description
244
+ # import inspect
245
+ # if description := inspect.getdoc(t):
246
+ # extra_fields = {"description": description}
247
+ # else:
248
+ extra_fields: dict[str, Any] = {}
249
+
250
+ if hasattr(t, "__annotations__") and t.__annotations__:
251
+ # It's a TypedDict...
252
+ return {
253
+ "type": "object",
254
+ "properties": {
255
+ k: self.type_to_schema_obj(v) for k, v in t.__annotations__.items()
256
+ },
257
+ **extra_fields,
258
+ }
259
+
260
+ if hasattr(t, "__origin__"):
261
+ if t.__origin__ is list:
262
+ return {
263
+ "type": "array",
264
+ "items": self.type_to_schema_obj(t.__args__[0]),
265
+ **extra_fields,
266
+ }
267
+ elif t.__origin__ is dict:
268
+ return {
269
+ "type": "object",
270
+ "properties": {
271
+ k: self.type_to_schema_obj(v)
272
+ for k, v in t.__args__[1].__annotations__.items()
273
+ },
274
+ **extra_fields,
275
+ }
276
+ else:
277
+ raise ValueError(f"Unknown type: {t}")
278
+
279
+ if hasattr(t, "__args__") and len(t.__args__) == 2 and type(None) in t.__args__:
280
+ return {
281
+ **self.type_to_schema_obj(t.__args__[0]),
282
+ "nullable": True,
283
+ **extra_fields,
284
+ }
285
+
286
+ type_mappings: dict[Any, dict] = {
287
+ str: {
288
+ "type": "string",
289
+ },
290
+ int: {
291
+ "type": "integer",
292
+ },
293
+ float: {
294
+ "type": "number",
295
+ },
296
+ bool: {
297
+ "type": "boolean",
298
+ },
299
+ dict: {
300
+ "type": "object",
301
+ },
302
+ list: {
303
+ "type": "array",
304
+ },
305
+ UUID: {
306
+ "type": "string",
307
+ "format": "uuid",
308
+ },
309
+ fields.IntegerField: {
310
+ "type": "integer",
311
+ },
312
+ fields.FloatField: {
313
+ "type": "number",
314
+ },
315
+ fields.DateTimeField: {
316
+ "type": "string",
317
+ "format": "date-time",
318
+ },
319
+ fields.DateField: {
320
+ "type": "string",
321
+ "format": "date",
322
+ },
323
+ fields.TimeField: {
324
+ "type": "string",
325
+ "format": "time",
326
+ },
327
+ fields.EmailField: {
328
+ "type": "string",
329
+ "format": "email",
330
+ },
331
+ fields.URLField: {
332
+ "type": "string",
333
+ "format": "uri",
334
+ },
335
+ fields.UUIDField: {
336
+ "type": "string",
337
+ "format": "uuid",
338
+ },
339
+ fields.DecimalField: {
340
+ "type": "number",
341
+ },
342
+ # fields.FileField: {
343
+ # "type": "string",
344
+ # "format": "binary",
345
+ # },
346
+ fields.ImageField: {
347
+ "type": "string",
348
+ "format": "binary",
349
+ },
350
+ fields.BooleanField: {
351
+ "type": "boolean",
352
+ },
353
+ fields.NullBooleanField: {
354
+ "type": "boolean",
355
+ "nullable": True,
356
+ },
357
+ fields.CharField: {
358
+ "type": "string",
359
+ },
360
+ fields.SlugField: {
361
+ "type": "string",
362
+ },
363
+ fields.EmailField: {
364
+ "type": "string",
365
+ "format": "email",
366
+ },
367
+ }
368
+
369
+ schema = type_mappings.get(t, {})
370
+ if not schema:
371
+ schema = type_mappings.get(t.__class__, {})
372
+ if not schema:
373
+ raise ValueError(f"Unknown type: {t}")
374
+
375
+ return {**schema, **extra_fields}
@@ -0,0 +1,42 @@
1
+ from plain import http
2
+
3
+
4
+ class JsonResponseList(http.JsonResponse):
5
+ openapi_description = "List of objects"
6
+
7
+ def __init__(self, data, *args, **kwargs):
8
+ if not isinstance(data, list):
9
+ raise TypeError("data must be a list")
10
+ kwargs["safe"] = False # Allow a list to be dumped instead of a dict
11
+ super().__init__(data, *args, **kwargs)
12
+
13
+
14
+ class JsonResponseCreated(http.JsonResponse):
15
+ status_code = 201
16
+ openapi_description = "Created"
17
+
18
+
19
+ class JsonResponseBadRequest(http.JsonResponse):
20
+ status_code = 400
21
+ openapi_description = "Bad request"
22
+
23
+
24
+ class HttpNoContentResponse(http.Response):
25
+ status_code = 204
26
+ openapi_description = "No content"
27
+
28
+
29
+ class Response(http.Response):
30
+ openapi_description = "OK"
31
+
32
+
33
+ class ResponseBadRequest(http.ResponseBadRequest):
34
+ openapi_description = "Bad request"
35
+
36
+
37
+ class ResponseNotFound(http.ResponseNotFound):
38
+ openapi_description = "Not found"
39
+
40
+
41
+ class JsonResponse(http.JsonResponse):
42
+ openapi_description = "OK"
@@ -0,0 +1,219 @@
1
+ import datetime
2
+ import json
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from plain.auth.views import AuthViewMixin
6
+ from plain.exceptions import ObjectDoesNotExist
7
+ from plain.views.base import View
8
+ from plain.views.csrf import CsrfExemptViewMixin
9
+ from plain.views.exceptions import ResponseException
10
+
11
+ from .responses import (
12
+ HttpNoContentResponse,
13
+ JsonResponse,
14
+ JsonResponseBadRequest,
15
+ JsonResponseCreated,
16
+ JsonResponseList,
17
+ ResponseBadRequest,
18
+ ResponseNotFound,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from plain.forms import BaseForm
23
+
24
+ from .models import APIKey
25
+
26
+
27
+ class APIAuthViewMixin(AuthViewMixin):
28
+ # Disable login redirects
29
+ login_url = None
30
+
31
+ def get_api_key(self) -> APIKey | None:
32
+ if "Authorization" in self.request.headers:
33
+ header_value = self.request.headers["Authorization"]
34
+ try:
35
+ header_token = header_value.split("Bearer ")[1]
36
+ except IndexError:
37
+ raise ResponseException(
38
+ ResponseBadRequest("Invalid Authorization header")
39
+ )
40
+
41
+ try:
42
+ api_key = APIKey.objects.get(token=header_token)
43
+ except APIKey.DoesNotExist:
44
+ raise ResponseException(ResponseBadRequest("Invalid API token"))
45
+
46
+ if api_key.expires_at and api_key.expires_at < datetime.datetime.now():
47
+ raise ResponseException(ResponseBadRequest("API token has expired"))
48
+
49
+ return api_key
50
+
51
+ def check_auth(self) -> None:
52
+ if not hasattr(self, "request"):
53
+ raise AttributeError(
54
+ "APIAuthViewMixin requires the request attribute to be set."
55
+ )
56
+
57
+ # If the user is already known, exit early
58
+ if self.request.user:
59
+ super().check_auth()
60
+ return
61
+
62
+ if api_key := self.get_api_key():
63
+ # Put the api_key on the request so we can access it
64
+ self.request.api_key = api_key
65
+
66
+ # Set the user if api_key has that attribute (typically from a OneToOneField)
67
+ if user := getattr(api_key, "user", None):
68
+ self.request.user = user
69
+
70
+ # Run the regular auth checks which will look for self.request.user
71
+ super().check_auth()
72
+
73
+
74
+ class APIBaseView(View):
75
+ form_class: type["BaseForm"] | None = None
76
+
77
+ def object_to_dict(self, obj): # Intentionally untyped
78
+ raise NotImplementedError(
79
+ f"object_to_dict() is not implemented on {self.__class__.__name__}"
80
+ )
81
+
82
+ def get_form_response(
83
+ self,
84
+ ) -> JsonResponse | JsonResponseCreated | JsonResponseBadRequest:
85
+ if self.form_class is None:
86
+ raise NotImplementedError(
87
+ f"form_class is not set on {self.__class__.__name__}"
88
+ )
89
+
90
+ form = self.form_class(**self.get_form_kwargs())
91
+
92
+ if form.is_valid():
93
+ return self.form_valid(form)
94
+ else:
95
+ return self.form_invalid(form)
96
+
97
+ def get_form_kwargs(self) -> dict[str, Any]:
98
+ if not self.request.body:
99
+ raise ResponseException(ResponseBadRequest("No JSON body provided"))
100
+
101
+ try:
102
+ data = json.loads(self.request.body)
103
+ except json.JSONDecodeError:
104
+ raise ResponseException(
105
+ JsonResponseBadRequest({"error": "Unable to parse JSON"})
106
+ )
107
+
108
+ return {
109
+ "data": data,
110
+ "files": self.request.FILES,
111
+ }
112
+
113
+ def form_valid(self, form: "BaseForm") -> JsonResponse | JsonResponseCreated:
114
+ """
115
+ Used for PUT and PATCH requests.
116
+ Can check self.request.method if you want different behavior.
117
+ """
118
+ object = form.save() # type: ignore
119
+ data = self.object_to_dict(object)
120
+
121
+ if self.request.method == "POST":
122
+ return JsonResponseCreated(
123
+ data,
124
+ json_dumps_params={
125
+ "sort_keys": True,
126
+ },
127
+ )
128
+ else:
129
+ return JsonResponse(
130
+ data,
131
+ json_dumps_params={
132
+ "sort_keys": True,
133
+ },
134
+ )
135
+
136
+ def form_invalid(self, form: "BaseForm") -> JsonResponseBadRequest:
137
+ return JsonResponseBadRequest(
138
+ {"message": "Invalid input", "errors": form.errors.get_json_data()},
139
+ )
140
+
141
+
142
+ class APIObjectListView(CsrfExemptViewMixin, APIBaseView):
143
+ def load_objects(self) -> None:
144
+ try:
145
+ self.objects = self.get_objects()
146
+ except ObjectDoesNotExist:
147
+ # Custom 404 with no body
148
+ raise ResponseException(ResponseNotFound())
149
+
150
+ if not self.objects:
151
+ # Also raise 404 if the object is None
152
+ raise ResponseException(ResponseNotFound())
153
+
154
+ def get_objects(self): # Intentionally untyped for subclasses to type
155
+ raise NotImplementedError(
156
+ f"get_objects() is not implemented on {self.__class__.__name__}"
157
+ )
158
+
159
+ def get(self) -> JsonResponseList | ResponseNotFound | ResponseBadRequest:
160
+ self.load_objects()
161
+ # TODO paginate??
162
+ data = [self.object_to_dict(obj) for obj in self.objects]
163
+ return JsonResponseList(data)
164
+
165
+ def post(
166
+ self,
167
+ ) -> JsonResponseCreated | ResponseNotFound | ResponseBadRequest:
168
+ self.load_objects()
169
+ return self.get_form_response() # type: ignore
170
+
171
+
172
+ class APIObjectView(CsrfExemptViewMixin, APIBaseView):
173
+ """Similar to a DetailView but without all of the context and template logic."""
174
+
175
+ def load_object(self) -> None:
176
+ try:
177
+ self.object = self.get_object()
178
+ except ObjectDoesNotExist:
179
+ # Custom 404 with no body
180
+ raise ResponseException(ResponseNotFound())
181
+
182
+ if not self.object:
183
+ # Also raise 404 if the object is None
184
+ raise ResponseException(ResponseNotFound())
185
+
186
+ def get_object(self): # Intentionally untyped for subclasses to type
187
+ """
188
+ Get an instance of an object (typically a model instance).
189
+
190
+ Authorization should be done here too.
191
+ """
192
+ raise NotImplementedError(
193
+ f"get_object() is not implemented on {self.__class__.__name__}"
194
+ )
195
+
196
+ def get_form_kwargs(self) -> dict[str, Any]:
197
+ kwargs = super().get_form_kwargs()
198
+ kwargs["instance"] = self.object
199
+ return kwargs
200
+
201
+ def get(self) -> JsonResponse | ResponseNotFound | ResponseBadRequest:
202
+ self.load_object()
203
+ data = self.object_to_dict(self.object)
204
+ return JsonResponse(data)
205
+
206
+ def put(self) -> JsonResponse | ResponseNotFound | ResponseBadRequest:
207
+ self.load_object()
208
+ return self.get_form_response()
209
+
210
+ def patch(self) -> JsonResponse | ResponseNotFound | ResponseBadRequest:
211
+ self.load_object()
212
+ return self.get_form_response()
213
+
214
+ def delete(
215
+ self,
216
+ ) -> HttpNoContentResponse | ResponseNotFound | ResponseBadRequest:
217
+ self.load_object()
218
+ self.object.delete()
219
+ return HttpNoContentResponse()
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "plain.api"
3
+ version = "0.1.0"
4
+ description = "API for Plain."
5
+ authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
+ readme = "README.md"
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "plain<1.0.0",
10
+ ]
11
+
12
+ [tool.uv]
13
+ dev-dependencies = [
14
+ "plain.auth<1.0.0",
15
+ "plain.pytest<1.0.0",
16
+ ]
17
+
18
+ [tool.uv.sources]
19
+ "plain.auth" = {path = "../plain-auth", editable = true}
20
+ "plain.pytest" = {path = "../plain-pytest", editable = true}
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["plain"]
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
@@ -0,0 +1,240 @@
1
+ version = 1
2
+ requires-python = ">=3.11"
3
+
4
+ [[package]]
5
+ name = "click"
6
+ version = "8.1.8"
7
+ source = { registry = "https://pypi.org/simple" }
8
+ dependencies = [
9
+ { name = "colorama", marker = "sys_platform == 'win32'" },
10
+ ]
11
+ sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
12
+ wheels = [
13
+ { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
14
+ ]
15
+
16
+ [[package]]
17
+ name = "colorama"
18
+ version = "0.4.6"
19
+ source = { registry = "https://pypi.org/simple" }
20
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
21
+ wheels = [
22
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
23
+ ]
24
+
25
+ [[package]]
26
+ name = "iniconfig"
27
+ version = "2.0.0"
28
+ source = { registry = "https://pypi.org/simple" }
29
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
30
+ wheels = [
31
+ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
32
+ ]
33
+
34
+ [[package]]
35
+ name = "jinja2"
36
+ version = "3.1.5"
37
+ source = { registry = "https://pypi.org/simple" }
38
+ dependencies = [
39
+ { name = "markupsafe" },
40
+ ]
41
+ sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674 }
42
+ wheels = [
43
+ { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 },
44
+ ]
45
+
46
+ [[package]]
47
+ name = "markupsafe"
48
+ version = "3.0.2"
49
+ source = { registry = "https://pypi.org/simple" }
50
+ sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
51
+ wheels = [
52
+ { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 },
53
+ { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 },
54
+ { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 },
55
+ { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 },
56
+ { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 },
57
+ { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 },
58
+ { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 },
59
+ { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 },
60
+ { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 },
61
+ { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 },
62
+ { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 },
63
+ { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 },
64
+ { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 },
65
+ { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 },
66
+ { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 },
67
+ { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 },
68
+ { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 },
69
+ { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 },
70
+ { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 },
71
+ { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 },
72
+ { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
73
+ { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
74
+ { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
75
+ { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
76
+ { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
77
+ { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
78
+ { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
79
+ { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
80
+ { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
81
+ { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
82
+ { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
83
+ { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
84
+ { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
85
+ { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
86
+ { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
87
+ { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
88
+ { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
89
+ { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
90
+ { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
91
+ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
92
+ ]
93
+
94
+ [[package]]
95
+ name = "packaging"
96
+ version = "24.2"
97
+ source = { registry = "https://pypi.org/simple" }
98
+ sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
99
+ wheels = [
100
+ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
101
+ ]
102
+
103
+ [[package]]
104
+ name = "plain"
105
+ version = "0.17.0"
106
+ source = { registry = "https://pypi.org/simple" }
107
+ dependencies = [
108
+ { name = "click" },
109
+ { name = "jinja2" },
110
+ ]
111
+ sdist = { url = "https://files.pythonhosted.org/packages/6d/dc/b8b5b47418f08e76cf028922204538ec160883f74c3c619b205c507de039/plain-0.17.0.tar.gz", hash = "sha256:2185f45711214cd406bb9640786a6bb3e3560ec39e377da9d31782d015c227b0", size = 180043 }
112
+ wheels = [
113
+ { url = "https://files.pythonhosted.org/packages/a4/7b/fc735376f195a3921c8dffb77689f4f31b909f011f7a50dcbe250603ebd2/plain-0.17.0-py3-none-any.whl", hash = "sha256:f37ad0beab5fbbb5ff957eee341301c94c23f92eed7ecc291f928bfd8efb313f", size = 219771 },
114
+ ]
115
+
116
+ [[package]]
117
+ name = "plain-api"
118
+ version = "0.1.0"
119
+ source = { editable = "." }
120
+ dependencies = [
121
+ { name = "plain" },
122
+ ]
123
+
124
+ [package.dev-dependencies]
125
+ dev = [
126
+ { name = "plain-auth" },
127
+ { name = "plain-pytest" },
128
+ ]
129
+
130
+ [package.metadata]
131
+ requires-dist = [{ name = "plain", specifier = "<1.0.0" }]
132
+
133
+ [package.metadata.requires-dev]
134
+ dev = [
135
+ { name = "plain-auth", editable = "../plain-auth" },
136
+ { name = "plain-pytest", editable = "../plain-pytest" },
137
+ ]
138
+
139
+ [[package]]
140
+ name = "plain-auth"
141
+ version = "0.3.1"
142
+ source = { editable = "../plain-auth" }
143
+ dependencies = [
144
+ { name = "plain" },
145
+ { name = "plain-models" },
146
+ { name = "plain-sessions" },
147
+ ]
148
+
149
+ [package.metadata]
150
+ requires-dist = [
151
+ { name = "plain", specifier = "<1.0.0" },
152
+ { name = "plain-models", specifier = "<1.0.0" },
153
+ { name = "plain-sessions", specifier = "<1.0.0" },
154
+ ]
155
+
156
+ [[package]]
157
+ name = "plain-models"
158
+ version = "0.11.1"
159
+ source = { registry = "https://pypi.org/simple" }
160
+ dependencies = [
161
+ { name = "plain" },
162
+ { name = "sqlparse" },
163
+ ]
164
+ sdist = { url = "https://files.pythonhosted.org/packages/e5/93/25618aed5eda2f26e55268273f065e4914cd6efddf523dedcb80e7f77dc9/plain_models-0.11.1.tar.gz", hash = "sha256:292bb64c6a93ef28c4ade764db6a421afd7626b101f0b302ab6b401ce2870003", size = 391913 }
165
+ wheels = [
166
+ { url = "https://files.pythonhosted.org/packages/23/43/3728df24062b51e41a8b6a6acb6c7faeb522f0160fc34eb7f543208af692/plain_models-0.11.1-py3-none-any.whl", hash = "sha256:f7510354b372f0e998fefc7112e483d73d0436046dcb61455867be7a31264257", size = 439851 },
167
+ ]
168
+
169
+ [[package]]
170
+ name = "plain-pytest"
171
+ version = "0.3.1"
172
+ source = { editable = "../plain-pytest" }
173
+ dependencies = [
174
+ { name = "click" },
175
+ { name = "plain" },
176
+ { name = "pytest" },
177
+ { name = "python-dotenv" },
178
+ ]
179
+
180
+ [package.metadata]
181
+ requires-dist = [
182
+ { name = "click", specifier = ">=8.0.0" },
183
+ { name = "plain", specifier = "<1.0.0" },
184
+ { name = "pytest", specifier = ">=7.0.0" },
185
+ { name = "python-dotenv", specifier = "~=1.0.0" },
186
+ ]
187
+
188
+ [[package]]
189
+ name = "plain-sessions"
190
+ version = "0.7.0"
191
+ source = { registry = "https://pypi.org/simple" }
192
+ dependencies = [
193
+ { name = "plain" },
194
+ ]
195
+ sdist = { url = "https://files.pythonhosted.org/packages/24/9f/dd7ee85aeca3e2d35d025644006547c7febf4e6b9827ae838df0f63ac9a6/plain_sessions-0.7.0.tar.gz", hash = "sha256:580d8a90cb9272870a41a65c0d4f5b609d1fb10c721ea73ab47a9397e02d5e68", size = 16587 }
196
+ wheels = [
197
+ { url = "https://files.pythonhosted.org/packages/70/e5/d50308b1039cd3573a5b03464021e521390c172c09d84ebf547dcc5ca024/plain_sessions-0.7.0-py3-none-any.whl", hash = "sha256:ad12acb6ae587abd187c57c59bca711ebdb895781a01c1eac42aea335f19c232", size = 13610 },
198
+ ]
199
+
200
+ [[package]]
201
+ name = "pluggy"
202
+ version = "1.5.0"
203
+ source = { registry = "https://pypi.org/simple" }
204
+ sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
205
+ wheels = [
206
+ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
207
+ ]
208
+
209
+ [[package]]
210
+ name = "pytest"
211
+ version = "8.3.4"
212
+ source = { registry = "https://pypi.org/simple" }
213
+ dependencies = [
214
+ { name = "colorama", marker = "sys_platform == 'win32'" },
215
+ { name = "iniconfig" },
216
+ { name = "packaging" },
217
+ { name = "pluggy" },
218
+ ]
219
+ sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 }
220
+ wheels = [
221
+ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 },
222
+ ]
223
+
224
+ [[package]]
225
+ name = "python-dotenv"
226
+ version = "1.0.1"
227
+ source = { registry = "https://pypi.org/simple" }
228
+ sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
229
+ wheels = [
230
+ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
231
+ ]
232
+
233
+ [[package]]
234
+ name = "sqlparse"
235
+ version = "0.5.3"
236
+ source = { registry = "https://pypi.org/simple" }
237
+ sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 }
238
+ wheels = [
239
+ { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 },
240
+ ]