Compare commits

..
Author SHA1 Message Date
dependabot[bot] c50c6f5517 Chore(deps): Bump filelock from 3.32.6 to 4.0.12 (#14336)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.32.6 to 4.0.12.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.32.6...4.0.12)

---
updated-dependencies:
- dependency-name: filelock
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 16:11:12 -07:00
8 changed files with 154 additions and 80 deletions

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ dependencies = [
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar>=2026.7.1,<2026.10", "drf-spectacular-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"filelock~=3.32.0", "filelock>=3.32,<4.1",
"flower>=2.0.1,<2.3", "flower>=2.0.1,<2.3",
"gotenberg-client[httpx]~=1.0", "gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17", "httpx-oauth~=0.17",
+1 -2
View File
@@ -34,7 +34,6 @@ from documents.models import DocumentType
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import PermissionsSpec
from documents.permissions import set_permissions_for_objects from documents.permissions import set_permissions_for_objects
from documents.plugins.helpers import DocumentsStatusManager from documents.plugins.helpers import DocumentsStatusManager
from documents.tasks import bulk_update_documents from documents.tasks import bulk_update_documents
@@ -446,7 +445,7 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
def set_permissions( def set_permissions(
doc_ids: list[int], doc_ids: list[int],
set_permissions: PermissionsSpec, set_permissions: dict,
*, *,
owner: User | None = None, owner: User | None = None,
merge: bool = False, merge: bool = False,
+3 -4
View File
@@ -38,8 +38,7 @@ from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.parsers import ParseError from documents.parsers import ParseError
from documents.permissions import PermissionsSpec from documents.permissions import set_permissions_for_object
from documents.permissions import set_permissions_for_objects
from documents.plugins.base import AlwaysRunPluginMixin from documents.plugins.base import AlwaysRunPluginMixin
from documents.plugins.base import ConsumeTaskPlugin from documents.plugins.base import ConsumeTaskPlugin
from documents.plugins.base import NoCleanupPluginMixin from documents.plugins.base import NoCleanupPluginMixin
@@ -949,7 +948,7 @@ class ConsumerPlugin(
or self.metadata.change_users is not None or self.metadata.change_users is not None
or self.metadata.change_groups is not None or self.metadata.change_groups is not None
): ):
permissions: PermissionsSpec = { permissions = {
"view": { "view": {
"users": self.metadata.view_users or [], "users": self.metadata.view_users or [],
"groups": self.metadata.view_groups or [], "groups": self.metadata.view_groups or [],
@@ -959,7 +958,7 @@ class ConsumerPlugin(
"groups": self.metadata.change_groups or [], "groups": self.metadata.change_groups or [],
}, },
} }
set_permissions_for_objects(permissions, Document, [document.pk]) set_permissions_for_object(permissions=permissions, object=document)
if self.metadata.custom_fields: if self.metadata.custom_fields:
for field in CustomField.objects.filter( for field in CustomField.objects.filter(
+120 -37
View File
@@ -1,10 +1,4 @@
from collections.abc import Iterable
from collections.abc import Mapping
from typing import Any from typing import Any
from typing import Final
from typing import Literal
from typing import TypeAlias
from typing import TypedDict
from typing import TypeVar from typing import TypeVar
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
@@ -26,29 +20,16 @@ from django.db.models.functions import Coalesce
from guardian.core import ObjectPermissionChecker from guardian.core import ObjectPermissionChecker
from guardian.models import GroupObjectPermission from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from guardian.shortcuts import get_users_with_perms
from guardian.shortcuts import remove_perm
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
from rest_framework.permissions import DjangoObjectPermissions from rest_framework.permissions import DjangoObjectPermissions
from documents.models import Document from documents.models import Document
from documents.versioning import get_root_document from documents.versioning import get_root_document
PermissionAction = Literal["view", "change"]
PERMISSION_ACTIONS: Final[tuple[PermissionAction, ...]] = ("view", "change")
class PermissionGrantees(TypedDict, total=False):
"""
The user and group ids to grant one permission action to. A missing key
leaves that kind of grant untouched.
"""
users: Iterable[int]
groups: Iterable[int]
PermissionsSpec: TypeAlias = Mapping[PermissionAction, PermissionGrantees]
class PaperlessObjectPermissions(DjangoObjectPermissions): class PaperlessObjectPermissions(DjangoObjectPermissions):
""" """
@@ -123,10 +104,87 @@ def get_groups_with_only_permission(obj, codename):
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct() return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
def set_permissions_for_object(
permissions: dict,
object,
*,
merge: bool = False,
) -> None:
"""
Set permissions for an object. The permissions are given as a mapping of actions
to a dict of user / group id lists, e.g.
{"view": {"users": [1], "groups": [2]}, "change": {"users": [], "groups": []}}.
If merge is True, the permissions are merged with the existing permissions and
no users or groups are removed. If False, the permissions are set to exactly
the given list of users and groups.
"""
for action, entry in permissions.items():
permission = f"{action}_{object.__class__.__name__.lower()}"
if "users" in entry:
# users
users_to_add = User.objects.filter(id__in=entry["users"])
users_to_remove = (
get_users_with_perms(
object,
only_with_perms_in=[permission],
with_group_users=False,
)
if not merge
else User.objects.none()
)
if users_to_add.exists() and users_to_remove.exists():
users_to_remove = users_to_remove.exclude(id__in=users_to_add)
if users_to_remove.exists():
for user in users_to_remove:
remove_perm(permission, user, object)
if users_to_add.exists():
for user in users_to_add:
assign_perm(permission, user, object)
if action == "change":
# change gives view too
assign_perm(
f"view_{object.__class__.__name__.lower()}",
user,
object,
)
if "groups" in entry:
# groups
groups_to_add = Group.objects.filter(id__in=entry["groups"])
groups_to_remove = (
get_groups_with_only_permission(
object,
permission,
)
if not merge
else Group.objects.none()
)
if groups_to_add.exists() and groups_to_remove.exists():
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
if groups_to_remove.exists():
for group in groups_to_remove:
remove_perm(permission, group, object)
if groups_to_add.exists():
for group in groups_to_add:
assign_perm(permission, group, object)
if action == "change":
# change gives view too
assign_perm(
f"view_{object.__class__.__name__.lower()}",
group,
object,
)
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]: def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
""" """
Resolve ``codenames`` to Permission rows, raising for any that don't exist Resolves `codenames` to Permission rows, raising like the single-object
so a bad action name fails loudly instead of granting nothing. assign_perm() this bulk path replaces does (via a `.get()` internally)
if any codename doesn't exist. SetPermissionsSerializer rejects unknown
action names at the API, but a caller passing one directly would
otherwise get a plain `.filter()` that silently builds zero rows and
no-ops instead of reporting the bad input.
""" """
permission_objs = list( permission_objs = list(
Permission.objects.filter(content_type=ctype, codename__in=codenames), Permission.objects.filter(content_type=ctype, codename__in=codenames),
@@ -145,13 +203,16 @@ def _apply_bulk_permission_entry(
perm_model: type[UserObjectPermission] | type[GroupObjectPermission], perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
identity_model: type[User] | type[Group], identity_model: type[User] | type[Group],
identity_field: str, identity_field: str,
ids: Iterable[int], ids: list[int],
codename: str, codename: str,
permission_objs: list[Permission], permission_objs: list[Permission],
ctype: ContentType, ctype: ContentType,
object_pks: list[str], object_pks: list[str],
merge: bool, merge: bool,
) -> None: ) -> None:
# Only the ids are needed to build permission rows (via `<field>_id=`),
# so avoid fetching full User/Group rows for identities that may not
# even end up being granted anything new.
add_ids = set( add_ids = set(
identity_model.objects.filter(id__in=ids).values_list("id", flat=True), identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
) )
@@ -189,26 +250,45 @@ def _apply_bulk_permission_entry(
for pk in object_pks for pk in object_pks
for identity_id in add_ids for identity_id in add_ids
] ]
# ignore_conflicts skips grants that already exist (unique constraint). # ignore_conflicts skips only rows that already exist as an exact
# (identity, permission, object) match -- the same de-dup the
# underlying (user|group, permission, object_pk) unique constraint
# already enforces for the single-object assign_perm() this replaces,
# so it doesn't change what counts as "already granted". batch_size
# caps how many rows go into a single INSERT statement.
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000) perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
def set_permissions_for_objects( def set_permissions_for_objects(
permissions: PermissionsSpec, permissions: dict,
model: type[Model], model: type[Model],
pks: QuerySet | list, pks: QuerySet | list,
*, *,
merge: bool = False, merge: bool = False,
) -> None: ) -> None:
""" """
Set the view/change grants for every object in ``pks``. With ``merge``, Bulk equivalent of set_permissions_for_object: applies the same
the given users and groups are added; without it, each given action's permission changes to every object identified by `pks` at once.
direct grants are replaced. Granting change also grants view, and an
action entry with neither key is left untouched.
guardian's bulk assign_perm is avoided because it skips the direct row Takes a model + pks (rather than model instances) deliberately -- the
for anyone who already has access through a group, so revoking the group permission rows built below only ever need `pk`, `content_type`, and
later would silently remove access that was granted explicitly. identity ids, so callers shouldn't have to fetch full rows (with every
other field) just to hand them to this function.
Deliberately does not use guardian's queryset/list-aware assign_perm:
passing a list as the object routes to bulk_assign_perm, which skips
creating a direct permission row for anyone who already has the
permission via ANY group membership (it checks
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
unlike the single-object assign_perm this replaces, which always
ensures a direct row via get_or_create regardless of group-derived
access. Losing that guarantee would mean a later revocation of the
group's grant silently strips access an admin explicitly asked to be
direct. Bulk-creating rows straight against the permission models
instead (see _apply_bulk_permission_entry) preserves the original
always-create-a-direct-row semantics while still batching every object
and every identity into one query per action, rather than one query per
(object, user) pair.
""" """
object_pks = [str(pk) for pk in pks] object_pks = [str(pk) for pk in pks]
if not object_pks: # pragma: no cover if not object_pks: # pragma: no cover
@@ -217,9 +297,12 @@ def set_permissions_for_objects(
model_name = model.__name__.lower() model_name = model.__name__.lower()
ctype = ContentType.objects.get_for_model(model) ctype = ContentType.objects.get_for_model(model)
# Resolve every action before writing, so a bad one can't leave the # Every action is resolved up front, before anything is written, so an
# actions ahead of it applied. # unrecognized action name (see _resolve_permissions) aborts the whole
permissions_by_action: dict[PermissionAction, list[Permission]] = {} # call instead of leaving the actions ahead of it already applied.
# SetPermissionsSerializer rejects unknown actions at the API, so this
# guards any other caller.
permissions_by_action: dict[str, list[Permission]] = {}
for action, entry in permissions.items(): for action, entry in permissions.items():
if "users" not in entry and "groups" not in entry: if "users" not in entry and "groups" not in entry:
continue continue
+11 -14
View File
@@ -78,16 +78,12 @@ from documents.models import WorkflowActionEmail
from documents.models import WorkflowActionWebhook from documents.models import WorkflowActionWebhook
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.parsers import is_mime_type_supported from documents.parsers import is_mime_type_supported
from documents.permissions import PERMISSION_ACTIONS
from documents.permissions import PermissionAction
from documents.permissions import PermissionGrantees
from documents.permissions import PermissionsSpec
from documents.permissions import get_document_count_filter_for_user from documents.permissions import get_document_count_filter_for_user
from documents.permissions import get_groups_with_only_permission from documents.permissions import get_groups_with_only_permission
from documents.permissions import has_perms_owner_aware from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids from documents.permissions import permitted_document_ids
from documents.permissions import restrict_queryset_to_visible from documents.permissions import restrict_queryset_to_visible
from documents.permissions import set_permissions_for_objects from documents.permissions import set_permissions_for_object
from documents.regex import validate_regex_pattern from documents.regex import validate_regex_pattern
from documents.templating.filepath import validate_filepath_template_and_render from documents.templating.filepath import validate_filepath_template_and_render
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
@@ -215,6 +211,9 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
return match return match
PERMISSION_ACTIONS = ("view", "change")
class SetPermissionsMixin: class SetPermissionsMixin:
def _validate_user_ids(self, user_ids): def _validate_user_ids(self, user_ids):
users = User.objects.none() users = User.objects.none()
@@ -236,10 +235,8 @@ class SetPermissionsMixin:
) )
return groups return groups
def validate_set_permissions(self, set_permissions=None) -> PermissionsSpec: def validate_set_permissions(self, set_permissions=None):
permissions_dict: dict[PermissionAction, PermissionGrantees] = { permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
action: {} for action in PERMISSION_ACTIONS
}
if set_permissions is not None: if set_permissions is not None:
for action in PERMISSION_ACTIONS: for action in PERMISSION_ACTIONS:
if action in set_permissions: if action in set_permissions:
@@ -247,18 +244,18 @@ class SetPermissionsMixin:
users = set_permissions[action]["users"] users = set_permissions[action]["users"]
permissions_dict[action]["users"] = self._validate_user_ids( permissions_dict[action]["users"] = self._validate_user_ids(
users, users,
).values_list("id", flat=True) )
if "groups" in set_permissions[action]: if "groups" in set_permissions[action]:
groups = set_permissions[action]["groups"] groups = set_permissions[action]["groups"]
permissions_dict[action]["groups"] = self._validate_group_ids( permissions_dict[action]["groups"] = self._validate_group_ids(
groups, groups,
).values_list("id", flat=True) )
else: else:
del permissions_dict[action] del permissions_dict[action]
return permissions_dict return permissions_dict
def _set_permissions(self, permissions: PermissionsSpec, object) -> None: def _set_permissions(self, permissions, object) -> None:
set_permissions_for_objects(permissions, type(object), [object.pk]) set_permissions_for_object(permissions, object)
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]): class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
@@ -3089,7 +3086,7 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
) )
return objects return objects
def _validate_permissions(self, permissions) -> PermissionsSpec: def _validate_permissions(self, permissions) -> dict:
return self.validate_set_permissions( return self.validate_set_permissions(
permissions, permissions,
) )
+5 -6
View File
@@ -23,7 +23,6 @@ from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import PermissionsSpec
from documents.permissions import set_permissions_for_objects from documents.permissions import set_permissions_for_objects
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory from paperless_testing.factories import DocumentFactory
@@ -446,7 +445,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
grant_object(self.group1, self.doc1, "view_document") grant_object(self.group1, self.doc1, "view_document")
permissions: PermissionsSpec = { permissions = {
"view": { "view": {
"users": [self.user1.id, self.user2.id], "users": [self.user1.id, self.user2.id],
"groups": [self.group2.id], "groups": [self.group2.id],
@@ -489,7 +488,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
grant_object(self.user1, self.doc1, "view_document") grant_object(self.user1, self.doc1, "view_document")
grant_object(self.group1, self.doc1, "view_document") grant_object(self.group1, self.doc1, "view_document")
permissions: PermissionsSpec = { permissions = {
"view": { "view": {
"users": [self.user2.id], "users": [self.user2.id],
"groups": [self.group2.id], "groups": [self.group2.id],
@@ -540,7 +539,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
batched call rather than one call per (document, identity) batched call rather than one call per (document, identity)
pair pair
""" """
permissions: PermissionsSpec = { permissions = {
"view": { "view": {
"users": [self.user1.id, self.user2.id], "users": [self.user1.id, self.user2.id],
"groups": [self.group2.id], "groups": [self.group2.id],
@@ -647,7 +646,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
""" """
with self.assertRaises(Permission.DoesNotExist): with self.assertRaises(Permission.DoesNotExist):
set_permissions_for_objects( set_permissions_for_objects(
{"not_a_real_action": {"users": [self.user1.id], "groups": []}}, # type: ignore[bad-assignment] {"not_a_real_action": {"users": [self.user1.id], "groups": []}},
Document, Document,
[self.doc1.pk], [self.doc1.pk],
) )
@@ -675,7 +674,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
set_permissions_for_objects( set_permissions_for_objects(
{ {
"view": {"users": [self.user1.id], "groups": []}, "view": {"users": [self.user1.id], "groups": []},
"not_a_real_action": {"users": [self.user1.id], "groups": []}, # type: ignore[bad-assignment] "not_a_real_action": {"users": [self.user1.id], "groups": []},
}, },
Document, Document,
[self.doc1.pk], [self.doc1.pk],
+9 -12
View File
@@ -7,8 +7,7 @@ from documents.data_models import DocumentMetadataOverrides
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.permissions import PermissionsSpec from documents.permissions import set_permissions_for_object
from documents.permissions import set_permissions_for_objects
from documents.templating.workflows import parse_w_workflow_placeholders from documents.templating.workflows import parse_w_workflow_placeholders
logger = logging.getLogger("paperless.workflows.mutations") logger = logging.getLogger("paperless.workflows.mutations")
@@ -74,7 +73,7 @@ def apply_assignment_to_document(
action.has_assign_change_groups, action.has_assign_change_groups,
], ],
): ):
permissions: PermissionsSpec = { permissions = {
"view": { "view": {
"users": action.assign_view_users.values_list("id", flat=True), "users": action.assign_view_users.values_list("id", flat=True),
"groups": action.assign_view_groups.values_list("id", flat=True), "groups": action.assign_view_groups.values_list("id", flat=True),
@@ -84,10 +83,9 @@ def apply_assignment_to_document(
"groups": action.assign_change_groups.values_list("id", flat=True), "groups": action.assign_change_groups.values_list("id", flat=True),
}, },
} }
set_permissions_for_objects( set_permissions_for_object(
permissions, permissions=permissions,
Document, object=document,
[document.pk],
merge=True, merge=True,
) )
@@ -245,14 +243,13 @@ def apply_removal_to_document(
document.owner = None document.owner = None
if action.remove_all_permissions: if action.remove_all_permissions:
permissions: PermissionsSpec = { permissions = {
"view": {"users": [], "groups": []}, "view": {"users": [], "groups": []},
"change": {"users": [], "groups": []}, "change": {"users": [], "groups": []},
} }
set_permissions_for_objects( set_permissions_for_object(
permissions, permissions=permissions,
Document, object=document,
[document.pk],
merge=False, merge=False,
) )
Generated
+4 -4
View File
@@ -1272,11 +1272,11 @@ wheels = [
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.32.6" version = "4.0.12"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/38/46/126b1831dca12060d4a8296bf9c4fe5c93c4f22197fa239cb0cc82042bba/filelock-3.32.6.tar.gz", hash = "sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c", size = 225172, upload-time = "2026-09-08T22:57:11.528Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/06/4f138f618dbea66803291274f228f01daf29f306fe8b96bc30dab765df75/filelock-3.32.6-py3-none-any.whl", hash = "sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1", size = 100189, upload-time = "2026-09-08T22:57:10.182Z" }, { url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" },
] ]
[[package]] [[package]]
@@ -3184,7 +3184,7 @@ requires-dist = [
{ name = "drf-spectacular", specifier = "~=0.30" }, { name = "drf-spectacular", specifier = "~=0.30" },
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" }, { name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
{ name = "drf-writable-nested", specifier = "~=0.7.1" }, { name = "drf-writable-nested", specifier = "~=0.7.1" },
{ name = "filelock", specifier = "~=3.32.0" }, { name = "filelock", specifier = ">=3.32,<4.1" },
{ name = "flower", specifier = ">=2.0.1,<2.3" }, { name = "flower", specifier = ">=2.0.1,<2.3" },
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" }, { name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" }, { name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },