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.
- plain_api-0.1.0/.gitignore +13 -0
- plain_api-0.1.0/LICENSE +28 -0
- plain_api-0.1.0/PKG-INFO +13 -0
- plain_api-0.1.0/README.md +3 -0
- plain_api-0.1.0/plain/api/README.md +1 -0
- plain_api-0.1.0/plain/api/__init__.py +0 -0
- plain_api-0.1.0/plain/api/config.py +6 -0
- plain_api-0.1.0/plain/api/forms.py +25 -0
- plain_api-0.1.0/plain/api/migrations/0001_initial.py +38 -0
- plain_api-0.1.0/plain/api/migrations/0002_apikey_expires_at.py +18 -0
- plain_api-0.1.0/plain/api/migrations/__init__.py +0 -0
- plain_api-0.1.0/plain/api/models.py +33 -0
- plain_api-0.1.0/plain/api/openapi.py +375 -0
- plain_api-0.1.0/plain/api/responses.py +42 -0
- plain_api-0.1.0/plain/api/views.py +219 -0
- plain_api-0.1.0/pyproject.toml +27 -0
- plain_api-0.1.0/uv.lock +240 -0
plain_api-0.1.0/LICENSE
ADDED
|
@@ -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.
|
plain_api-0.1.0/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
1
|
+
# plain.api
|
|
File without changes
|
|
@@ -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"
|
plain_api-0.1.0/uv.lock
ADDED
|
@@ -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
|
+
]
|