Merge branch 'main' into dev

# Conflicts:
#	docs/usage.md
#	src/documents/signals/handlers.py
#	src/documents/tests/test_api_documents.py
#	src/documents/views.py
This commit is contained in:
shamoon
2026-04-14 15:11:23 -07:00
22 changed files with 313 additions and 36 deletions
+1 -1
View File
@@ -842,7 +842,7 @@ MariaDB: `mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql -p`
## Barcodes {#barcodes}
Paperless is able to utilize barcodes for automatically performing some tasks.
Paperless is able to utilize barcodes for automatically performing some tasks. Barcodes are only supported for PDF documents or TIFF, [if enabled](configuration.md#PAPERLESS_CONSUMER_BARCODE_TIFF_SUPPORT).
At this time, the library utilized for detection of barcodes supports the following types:
+35
View File
@@ -1,5 +1,40 @@
# Changelog
## paperless-ngx 2.20.14
### Bug Fixes
- Fix: do not submit permissions for non-owners [@shamoon](https://github.com/shamoon) ([#12571](https://github.com/paperless-ngx/paperless-ngx/pull/12571))
- Fix: prevent duplicate parent tag IDs [@shamoon](https://github.com/shamoon) ([#12522](https://github.com/paperless-ngx/paperless-ngx/pull/12522))
- Fix: dont defer tag change application in workflows [@shamoon](https://github.com/shamoon) ([#12478](https://github.com/paperless-ngx/paperless-ngx/pull/12478))
- Fix: limit share link viewset actions [@shamoon](https://github.com/shamoon) ([#12461](https://github.com/paperless-ngx/paperless-ngx/pull/12461))
- Fix: add fallback ordering for documents by id after created [@shamoon](https://github.com/shamoon) ([#12440](https://github.com/paperless-ngx/paperless-ngx/pull/12440))
- Fixhancement: default mail-created correspondent matching to exact [@shamoon](https://github.com/shamoon) ([#12414](https://github.com/paperless-ngx/paperless-ngx/pull/12414))
- Fix: validate date CF value in serializer [@shamoon](https://github.com/shamoon) ([#12410](https://github.com/paperless-ngx/paperless-ngx/pull/12410))
### All App Changes
<details>
<summary>7 changes</summary>
- Fix: do not submit permissions for non-owners [@shamoon](https://github.com/shamoon) ([#12571](https://github.com/paperless-ngx/paperless-ngx/pull/12571))
- Fix: prevent duplicate parent tag IDs [@shamoon](https://github.com/shamoon) ([#12522](https://github.com/paperless-ngx/paperless-ngx/pull/12522))
- Fix: dont defer tag change application in workflows [@shamoon](https://github.com/shamoon) ([#12478](https://github.com/paperless-ngx/paperless-ngx/pull/12478))
- Fix: limit share link viewset actions [@shamoon](https://github.com/shamoon) ([#12461](https://github.com/paperless-ngx/paperless-ngx/pull/12461))
- Fix: add fallback ordering for documents by id after created [@shamoon](https://github.com/shamoon) ([#12440](https://github.com/paperless-ngx/paperless-ngx/pull/12440))
- Fixhancement: default mail-created correspondent matching to exact [@shamoon](https://github.com/shamoon) ([#12414](https://github.com/paperless-ngx/paperless-ngx/pull/12414))
- Fix: validate date CF value in serializer [@shamoon](https://github.com/shamoon) ([#12410](https://github.com/paperless-ngx/paperless-ngx/pull/12410))
</details>
## paperless-ngx 2.20.13
### Bug Fixes
- Fix: suggest corrections only if visible results
- Fix: require view permission for more-like search
- Fix: validate document link targets
- Fix: enforce permissions when attaching accounts to mail rules
## paperless-ngx 2.20.12
### Security
+3 -1
View File
@@ -417,7 +417,7 @@ still have "object-level" permissions.
| SystemStatus | View the system status dialog and corresponding API endpoint. Admin users also retain system status access. |
| Tag | Add, edit, delete or view Tags. |
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
| User | Add, edit, delete or view Users. |
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
#### Detailed Explanation of Object Permissions {#object-permissions}
@@ -428,6 +428,8 @@ still have "object-level" permissions.
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
For related metadata such as tags, correspondents, document types, and storage paths, object visibility and document assignment are intentionally distinct. A user may still retain or submit a known object ID when editing a document even if that related object is displayed as _Private_ or omitted from search and selection results. This allows documents to preserve existing assignments that the current user cannot necessarily inspect in detail.
### Password reset
In order to enable the password reset feature you will need to setup an SMTP backend, see
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "2.20.13"
version = "2.20.14"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "2.20.13",
"version": "2.20.14",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -26,6 +26,7 @@ import {
} from 'src/app/data/matching-model'
import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { PermissionsService } from 'src/app/services/permissions.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -87,6 +88,7 @@ describe('EditDialogComponent', () => {
let component: TestComponent
let fixture: ComponentFixture<TestComponent>
let tagService: TagService
let permissionsService: PermissionsService
let settingsService: SettingsService
let activeModal: NgbActiveModal
let httpTestingController: HttpTestingController
@@ -118,8 +120,10 @@ describe('EditDialogComponent', () => {
}).compileComponents()
tagService = TestBed.inject(TagService)
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = currentUser
permissionsService.initialize([], currentUser as any)
activeModal = TestBed.inject(NgbActiveModal)
httpTestingController = TestBed.inject(HttpTestingController)
@@ -226,6 +230,25 @@ describe('EditDialogComponent', () => {
expect(updateSpy).toHaveBeenCalled()
})
it('should not submit owner or permissions for non-owner edits', () => {
component.object = tag
component.dialogMode = EditDialogMode.EDIT
component.ngOnInit()
component.objectForm.get('name').setValue('Updated tag')
component.save()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tags/${tag.id}/`
)
expect(req.request.method).toEqual('PUT')
expect(req.request.body.name).toEqual('Updated tag')
expect(req.request.body.owner).toEqual(tag.owner)
expect(req.request.body.set_permissions).toBeUndefined()
req.flush({})
})
it('should create an object on save in edit mode', () => {
const createSpy = jest.spyOn(tagService, 'create')
component.dialogMode = EditDialogMode.CREATE
@@ -18,6 +18,7 @@ import { ObjectWithId } from 'src/app/data/object-with-id'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { PermissionsService } from 'src/app/services/permissions.service'
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -42,6 +43,7 @@ export abstract class EditDialogComponent<
protected activeModal = inject(NgbActiveModal)
protected userService = inject(UserService)
protected settingsService = inject(SettingsService)
protected permissionsService = inject(PermissionsService)
users: User[]
@@ -69,10 +71,6 @@ export abstract class EditDialogComponent<
ngOnInit(): void {
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
if ((this.object as ObjectWithPermissions).permissions) {
this.object['set_permissions'] = this.object['permissions']
}
this.object['permissions_form'] = {
owner: (this.object as ObjectWithPermissions).owner,
set_permissions: (this.object as ObjectWithPermissions).permissions,
@@ -151,18 +149,28 @@ export abstract class EditDialogComponent<
return Object.assign({}, this.objectForm.value)
}
protected shouldSubmitPermissions(): boolean {
return (
this.dialogMode === EditDialogMode.CREATE ||
this.permissionsService.currentUserOwnsObject(this.object)
)
}
save() {
this.error = null
const formValues = this.getFormValues()
const permissionsObject: PermissionsFormObject =
this.objectForm.get('permissions_form')?.value
if (permissionsObject) {
if (permissionsObject && this.shouldSubmitPermissions()) {
formValues.owner = permissionsObject.owner
formValues.set_permissions = permissionsObject.set_permissions
delete formValues.permissions_form
}
delete formValues.permissions_form
var newObject = Object.assign(Object.assign({}, this.object), formValues)
if (!this.shouldSubmitPermissions()) {
delete newObject['set_permissions']
}
var serverResponse: Observable<T>
switch (this.dialogMode) {
case EditDialogMode.CREATE:
@@ -9,7 +9,6 @@ import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user'
import { PermissionsService } from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -39,7 +38,6 @@ export class UserEditDialogComponent
implements OnInit
{
private toastService = inject(ToastService)
private permissionsService = inject(PermissionsService)
private groupsService: GroupService
groups: Group[]
@@ -205,6 +205,20 @@ describe('TagsComponent', () => {
expect(component.value).toEqual([2, 1])
})
it('should not duplicate parents when adding sibling nested tags', () => {
const root: Tag = { id: 1, name: 'root' }
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
const leafA: Tag = { id: 3, name: 'leaf-a', parent: 2 }
const leafB: Tag = { id: 4, name: 'leaf-b', parent: 2 }
component.tags = [root, parent, leafA, leafB]
component.value = []
component.addTag(3)
component.addTag(4)
expect(component.value).toEqual([3, 2, 1, 4])
})
it('should return ancestors from root to parent using getParentChain', () => {
const root: Tag = { id: 1, name: 'root' }
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
@@ -153,11 +153,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
}
public onAdd(tag: Tag) {
if (tag.parent) {
if (tag?.parent) {
// add all parents recursively
const parent = this.getTag(tag.parent)
this.value = [...this.value, parent.id]
this.onAdd(parent)
if (parent && !this.value.includes(parent.id)) {
this.value = [...this.value, parent.id]
this.onAdd(parent)
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: 'Paperless-ngx',
tag: 'prod',
version: '2.20.13',
version: '2.20.14',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
+2
View File
@@ -911,6 +911,8 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
getattr(request, "user", None) if request is not None else None,
doc_ids,
)
elif field.data_type == CustomField.FieldDataType.DATE:
data["value"] = serializers.DateField().to_internal_value(data["value"])
return data
+1 -4
View File
@@ -894,7 +894,6 @@ def run_workflows(
# Refresh this so the matching data is fresh and instance fields are re-freshed
# Otherwise, this instance might be behind and overwrite the work another process did
document.refresh_from_db()
doc_tag_ids = list(document.tags.values_list("pk", flat=True))
except Document.DoesNotExist:
# Document was hard deleted by a previous workflow or another process
logger.info(
@@ -928,14 +927,13 @@ def run_workflows(
apply_assignment_to_document(
action,
document,
doc_tag_ids,
logging_group,
)
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
if use_overrides and overrides:
apply_removal_to_overrides(action, overrides)
else:
apply_removal_to_document(action, document, doc_tag_ids)
apply_removal_to_document(action, document)
elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
context = build_workflow_action_context(document, overrides)
execute_email_action(
@@ -982,7 +980,6 @@ def run_workflows(
"modified",
],
)
document.tags.set(doc_tag_ids)
WorkflowRun.objects.create(
workflow=workflow,
@@ -1320,3 +1320,41 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.data["results"]
self.assertEqual(results[0]["document_count"], 0)
def test_patch_document_invalid_date_custom_field_returns_validation_error(self):
"""
GIVEN:
- A date custom field
- A document
WHEN:
- Patching the document with a date string in the wrong format
THEN:
- HTTP 400 is returned instead of an internal server error
- No custom field instance is created
"""
cf_date = CustomField.objects.create(
name="datefield",
data_type=CustomField.FieldDataType.DATE,
)
doc = Document.objects.create(
title="Doc",
checksum="123",
mime_type="application/pdf",
)
response = self.client.patch(
f"/api/documents/{doc.pk}/",
{
"custom_fields": [
{
"field": cf_date.pk,
"value": "10.03.2026",
},
],
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("custom_fields", response.data)
self.assertEqual(CustomFieldInstance.objects.count(), 0)
+38 -1
View File
@@ -1168,6 +1168,43 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertIn("all", response.data)
self.assertCountEqual(response.data["all"], [d.id for d in docs])
def test_default_ordering_uses_id_as_tiebreaker(self):
"""
GIVEN:
- Documents sharing the same created date
WHEN:
- API request for documents without an explicit ordering
THEN:
- Results are correctly ordered by created > id
"""
older_doc = Document.objects.create(
checksum="older",
content="older",
created=date(2024, 1, 1),
)
first_same_date_doc = Document.objects.create(
checksum="same-date-1",
content="same-date-1",
created=date(2024, 1, 2),
)
second_same_date_doc = Document.objects.create(
checksum="same-date-2",
content="same-date-2",
created=date(2024, 1, 2),
)
response = self.client.get("/api/documents/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
[result["id"] for result in response.data["results"]],
[
second_same_date_doc.id,
first_same_date_doc.id,
older_doc.id,
],
)
def test_list_with_include_selection_data(self) -> None:
correspondent = Correspondent.objects.create(name="c1")
doc_type = DocumentType.objects.create(name="dt1")
@@ -3379,7 +3416,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
self.assertEqual(create_resp.data["document"], doc.pk)
def test_next_asn(self) -> None:
def test_next_asn(self):
"""
GIVEN:
- Existing documents with ASNs, highest owned by user2
+118
View File
@@ -3757,6 +3757,124 @@ class TestWorkflows(
as_json=False,
)
@mock.patch("documents.signals.handlers.execute_webhook_action")
def test_workflow_webhook_action_does_not_overwrite_concurrent_tags(
self,
mock_execute_webhook_action,
):
"""
GIVEN:
- A document updated workflow with only a webhook action
- A tag update that happens after run_workflows
WHEN:
- The workflow runs
THEN:
- The concurrent tag update is preserved
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
webhook_action = WorkflowActionWebhook.objects.create(
use_params=False,
body="Test message: {{doc_url}}",
url="http://paperless-ngx.com",
include_document=False,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.WEBHOOK,
webhook=webhook_action,
)
w = Workflow.objects.create(
name="Webhook workflow",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
inbox_tag = Tag.objects.create(name="inbox")
error_tag = Tag.objects.create(name="error")
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
doc.tags.add(inbox_tag)
def add_error_tag(*args, **kwargs):
Document.objects.get(pk=doc.pk).tags.add(error_tag)
mock_execute_webhook_action.side_effect = add_error_tag
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
doc.refresh_from_db()
self.assertCountEqual(doc.tags.all(), [inbox_tag, error_tag])
@mock.patch("documents.signals.handlers.execute_webhook_action")
def test_workflow_tag_actions_do_not_overwrite_concurrent_tags(
self,
mock_execute_webhook_action,
):
"""
GIVEN:
- A document updated workflow that clears tags and assigns an inbox tag
- A later tag update that happens before the workflow finishes
WHEN:
- The workflow runs
THEN:
- The later tag update is preserved
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
removal_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.REMOVAL,
remove_all_tags=True,
)
assign_action = WorkflowAction.objects.create(
assign_owner=self.user2,
)
assign_action.assign_tags.add(self.t1)
webhook_action = WorkflowActionWebhook.objects.create(
use_params=False,
body="Test message: {{doc_url}}",
url="http://paperless-ngx.com",
include_document=False,
)
notify_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.WEBHOOK,
webhook=webhook_action,
)
w = Workflow.objects.create(
name="Workflow tag race",
order=0,
)
w.triggers.add(trigger)
w.actions.add(removal_action)
w.actions.add(assign_action)
w.actions.add(notify_action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
owner=self.user3,
)
doc.tags.add(self.t2, self.t3)
def add_error_tag(*args, **kwargs):
Document.objects.get(pk=doc.pk).tags.add(self.t2)
mock_execute_webhook_action.side_effect = add_error_tag
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
doc.refresh_from_db()
self.assertEqual(doc.owner, self.user2)
self.assertCountEqual(doc.tags.all(), [self.t1, self.t2])
@override_settings(
PAPERLESS_URL="http://localhost:8000",
)
+10 -2
View File
@@ -87,6 +87,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter
from rest_framework.filters import SearchFilter
from rest_framework.generics import GenericAPIView
from rest_framework.mixins import CreateModelMixin
from rest_framework.mixins import DestroyModelMixin
from rest_framework.mixins import ListModelMixin
from rest_framework.mixins import RetrieveModelMixin
@@ -909,7 +910,7 @@ class DocumentViewSet(
return (
Document.objects.filter(root_document__isnull=True)
.distinct()
.order_by("-created")
.order_by("-created", "-id")
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
.annotate(num_notes=Count("notes"))
.select_related("correspondent", "storage_path", "document_type", "owner")
@@ -3739,7 +3740,14 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]):
)
class ShareLinkViewSet(PassUserMixin, ModelViewSet[ShareLink]):
class ShareLinkViewSet(
PassUserMixin,
CreateModelMixin,
RetrieveModelMixin,
DestroyModelMixin,
ListModelMixin,
GenericViewSet,
):
model = ShareLink
queryset = ShareLink.objects.all()
+4 -10
View File
@@ -16,7 +16,6 @@ logger = logging.getLogger("paperless.workflows.mutations")
def apply_assignment_to_document(
action: WorkflowAction,
document: Document,
doc_tag_ids: list[int],
logging_group,
):
"""
@@ -25,12 +24,7 @@ def apply_assignment_to_document(
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
"""
if action.has_assign_tags:
tag_ids_to_add: set[int] = set()
for tag in action.assign_tags.all():
tag_ids_to_add.add(tag.pk)
tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
doc_tag_ids[:] = list(set(doc_tag_ids) | tag_ids_to_add)
document.add_nested_tags(action.assign_tags.all())
if action.assign_correspondent:
document.correspondent = action.assign_correspondent
@@ -200,7 +194,6 @@ def apply_assignment_to_overrides(
def apply_removal_to_document(
action: WorkflowAction,
document: Document,
doc_tag_ids: list[int],
):
"""
Apply removal actions to a Document instance.
@@ -209,14 +202,15 @@ def apply_removal_to_document(
"""
if action.remove_all_tags:
doc_tag_ids.clear()
document.tags.clear()
else:
tag_ids_to_remove: set[int] = set()
for tag in action.remove_tags.all():
tag_ids_to_remove.add(tag.pk)
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove]
if tag_ids_to_remove:
document.tags.remove(*tag_ids_to_remove)
if action.remove_all_correspondents or (
document.correspondent
+1 -1
View File
@@ -1,6 +1,6 @@
from typing import Final
__version__: Final[tuple[int, int, int]] = (2, 20, 13)
__version__: Final[tuple[int, int, int]] = (2, 20, 14)
# Version string like X.Y.Z
__full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y
+1
View File
@@ -481,6 +481,7 @@ class MailAccountHandler(LoggingMixin):
name=name,
defaults={
"match": name,
"matching_algorithm": Correspondent.MATCH_LITERAL,
},
)[0]
except DatabaseError as e:
+1 -1
View File
@@ -450,7 +450,7 @@ class TestMail(
c = handler._get_correspondent(message, rule)
self.assertIsNotNone(c)
self.assertEqual(c.name, "someone@somewhere.com")
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_ANY)
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_LITERAL)
self.assertEqual(c.match, "someone@somewhere.com")
c = handler._get_correspondent(message2, rule)
self.assertIsNotNone(c)
Generated
+1 -1
View File
@@ -2854,7 +2854,7 @@ wheels = [
[[package]]
name = "paperless-ngx"
version = "2.20.13"
version = "2.20.14"
source = { virtual = "." }
dependencies = [
{ name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },