mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-29 04:40:30 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61f36eb253 | ||
|
|
e50bcc2efb | ||
|
|
88000aab45 | ||
|
|
01b4ab392c | ||
|
|
52aa8758e3 | ||
|
|
4b7cc1c0c3 | ||
|
|
f02d7455be | ||
|
|
a61c35fcd8 | ||
|
|
64552d5d5b | ||
|
|
a59edf3e5b | ||
|
|
6a5d06ee26 | ||
|
|
126ec414a8 | ||
|
|
773744d04b | ||
|
|
7b98085b31 | ||
|
|
b3bf220f56 | ||
|
|
d4429c3cc7 | ||
|
|
4f777d7438 | ||
|
|
2a44d8b5ba | ||
|
|
abf5050ea7 | ||
|
|
091ddf7c45 | ||
|
|
c9f7f2cfbe | ||
|
|
b457610ffb | ||
|
|
969c2ea0e2 | ||
|
|
31b806a285 | ||
|
|
99851b418c | ||
|
|
e34eda07bb | ||
|
|
793459b416 | ||
|
|
04297fd02c | ||
|
|
1b277dd8e1 | ||
|
|
3c20abeb4c | ||
|
|
b11f1f8459 | ||
|
|
7424e7ce0b | ||
|
|
a53a3d3769 |
@@ -59,7 +59,6 @@ updates:
|
|||||||
- "drf-*"
|
- "drf-*"
|
||||||
- "djangorestframework"
|
- "djangorestframework"
|
||||||
- "whitenoise"
|
- "whitenoise"
|
||||||
- "bleach"
|
|
||||||
- "jinja2"
|
- "jinja2"
|
||||||
# Async, Task Queuing & Caching
|
# Async, Task Queuing & Caching
|
||||||
async-tasks:
|
async-tasks:
|
||||||
|
|||||||
+706
-456
File diff suppressed because it is too large
Load Diff
+4804
-12941
File diff suppressed because one or more lines are too long
+3
-1
@@ -171,7 +171,9 @@ RUN set -eux \
|
|||||||
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
|
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
|
||||||
&& echo "Cleaning up image layer" \
|
&& echo "Cleaning up image layer" \
|
||||||
&& rm --force --verbose *.deb \
|
&& rm --force --verbose *.deb \
|
||||||
&& rm --recursive --force --verbose /var/lib/apt/lists/*
|
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
||||||
|
&& echo "Configuring interactive shells to source the s6 container environment" \
|
||||||
|
&& echo '. /etc/profile.d/contenv.sh' >> /etc/bash.bashrc
|
||||||
|
|
||||||
WORKDIR /usr/src/paperless/src/
|
WORKDIR /usr/src/paperless/src/
|
||||||
|
|
||||||
|
|||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Source s6 container environment for interactive shells.
|
||||||
|
# Ensures variables resolved from *_FILE secret injection are visible
|
||||||
|
# when using 'docker exec bash'. Does not affect s6 services (those
|
||||||
|
# use with-contenv directly). Has no effect in non-container contexts
|
||||||
|
# because the directory will not exist.
|
||||||
|
# Note: sh/dash shells opened via 'docker exec sh' are not covered;
|
||||||
|
# only bash-based sessions benefit from this file.
|
||||||
|
_pngx_contenv="/run/s6/container_environment"
|
||||||
|
if [ -d "${_pngx_contenv}" ]; then
|
||||||
|
for _pngx_f in "${_pngx_contenv}"/*; do
|
||||||
|
[ -f "${_pngx_f}" ] || continue
|
||||||
|
_pngx_name=$(basename "${_pngx_f}")
|
||||||
|
_pngx_val=$(cat "${_pngx_f}")
|
||||||
|
export "${_pngx_name}=${_pngx_val}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
unset _pngx_contenv _pngx_f _pngx_name _pngx_val
|
||||||
@@ -153,8 +153,11 @@ in similar existing documents, and the document chat can retrieve relevant conte
|
|||||||
|
|
||||||
Enable it by setting
|
Enable it by setting
|
||||||
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
|
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
|
||||||
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). The index is only
|
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). By default, the main
|
||||||
built when AI is enabled **and** an embedding backend is set.
|
LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY)
|
||||||
|
and [endpoint](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) can be configured.
|
||||||
|
|
||||||
|
The index is only built when AI is enabled **and** an embedding backend is set.
|
||||||
|
|
||||||
The index is updated automatically on a schedule controlled by
|
The index is updated automatically on a schedule controlled by
|
||||||
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
||||||
|
|||||||
+21
-6
@@ -1576,9 +1576,6 @@ ports.
|
|||||||
#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=<bool>`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
|
#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=<bool>`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
|
||||||
|
|
||||||
: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
|
: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
|
||||||
A hostname is blocked if any of the addresses it resolves to is non-public.
|
|
||||||
Webhook requests connect directly, without using the `HTTP_PROXY` or
|
|
||||||
`HTTPS_PROXY` environment variables, and never follow redirects.
|
|
||||||
|
|
||||||
Defaults to true, which allows internal requests.
|
Defaults to true, which allows internal requests.
|
||||||
|
|
||||||
@@ -1587,7 +1584,7 @@ Webhook requests connect directly, without using the `HTTP_PROXY` or
|
|||||||
#### [`PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS=<bool>`](#PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS) {#PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS}
|
#### [`PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS=<bool>`](#PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS) {#PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS}
|
||||||
|
|
||||||
: If set to false, incoming mail account connections are blocked when the
|
: If set to false, incoming mail account connections are blocked when the
|
||||||
configured IMAP hostname resolves to any non-public address (for example,
|
configured IMAP hostname resolves to a non-public address (for example,
|
||||||
localhost, link-local, or RFC1918 private ranges).
|
localhost, link-local, or RFC1918 private ranges).
|
||||||
|
|
||||||
Defaults to true, which allows internal hosts.
|
Defaults to true, which allows internal hosts.
|
||||||
@@ -2136,6 +2133,13 @@ for language and resource considerations.
|
|||||||
|
|
||||||
Defaults to None.
|
Defaults to None.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_AI_LLM_EMBEDDING_API_KEY=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) {#PAPERLESS_AI_LLM_EMBEDDING_API_KEY}
|
||||||
|
|
||||||
|
: The API key to use for the embedding backend. If not supplied, embeddings use
|
||||||
|
`PAPERLESS_AI_LLM_API_KEY`.
|
||||||
|
|
||||||
|
Defaults to None.
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
|
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
|
||||||
|
|
||||||
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
|
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
|
||||||
@@ -2217,11 +2221,22 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
|
|||||||
#### [`PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS}
|
#### [`PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS}
|
||||||
|
|
||||||
: If set to false, Paperless blocks AI endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
|
: If set to false, Paperless blocks AI endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
|
||||||
A hostname is blocked if any of the addresses it resolves to is non-public, and redirects are checked the same way.
|
|
||||||
Requests to a configured AI endpoint connect directly, without using the `HTTP_PROXY` or `HTTPS_PROXY` environment variables.
|
|
||||||
|
|
||||||
Defaults to true, which allows internal endpoints.
|
Defaults to true, which allows internal endpoints.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_AI_LLM_EXTRA_PARAMS=<json>`](#PAPERLESS_AI_LLM_EXTRA_PARAMS) {#PAPERLESS_AI_LLM_EXTRA_PARAMS}
|
||||||
|
|
||||||
|
: A JSON object of extra parameters sent with every LLM request, for providers that require a parameter Paperless does not
|
||||||
|
set itself. Values here override Paperless' own, and no validation is performed. Whatever you put here is passed to the
|
||||||
|
backend as-is, so an invalid parameter will simply be rejected by your provider. For example, current OpenAI reasoning
|
||||||
|
models refuse tool calls on the chat completions API unless reasoning is off:
|
||||||
|
|
||||||
|
```
|
||||||
|
PAPERLESS_AI_LLM_EXTRA_PARAMS={"reasoning_effort": "none"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults to empty, which adds nothing to requests.
|
||||||
|
|
||||||
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
||||||
|
|
||||||
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
||||||
|
|||||||
+1
-1
@@ -613,7 +613,7 @@ The following workflow action types are available:
|
|||||||
- The request headers as key-value pairs
|
- The request headers as key-value pairs
|
||||||
|
|
||||||
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
|
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
|
||||||
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. Webhook requests connect directly (proxy environment variables are not used) and do not follow redirects. If you are allowing non-admins to create workflows,
|
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
|
||||||
you may want to adjust these settings to prevent abuse.
|
you may want to adjust these settings to prevent abuse.
|
||||||
|
|
||||||
##### Move to Trash {#workflow-action-move-to-trash}
|
##### Move to Trash {#workflow-action-move-to-trash}
|
||||||
|
|||||||
+2
-5
@@ -17,10 +17,8 @@ classifiers = [
|
|||||||
# TODO: Move certain things to groups and then utilize that further
|
# TODO: Move certain things to groups and then utilize that further
|
||||||
# This will allow testing to not install a webserver, mysql, etc
|
# This will allow testing to not install a webserver, mysql, etc
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyio>=4.12",
|
|
||||||
"azure-ai-documentintelligence>=1.0.2",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
"babel>=2.17",
|
"babel>=2.17",
|
||||||
"bleach~=6.4.0",
|
|
||||||
"celery[redis]~=5.6.2",
|
"celery[redis]~=5.6.2",
|
||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
"channels-redis~=4.2",
|
"channels-redis~=4.2",
|
||||||
@@ -48,8 +46,6 @@ dependencies = [
|
|||||||
"filelock~=3.32.0",
|
"filelock~=3.32.0",
|
||||||
"flower>=2.0.1,<2.2",
|
"flower>=2.0.1,<2.2",
|
||||||
"gotenberg-client[httpx]~=1.0",
|
"gotenberg-client[httpx]~=1.0",
|
||||||
"httpcore~=1.0.9",
|
|
||||||
"httpx~=0.28.1",
|
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
"ijson>=3.5.1",
|
"ijson>=3.5.1",
|
||||||
"imap-tools>=1.14,<1.16",
|
"imap-tools>=1.14,<1.16",
|
||||||
@@ -80,6 +76,7 @@ dependencies = [
|
|||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client[httpx]~=1.0",
|
"tika-client[httpx]~=1.0",
|
||||||
"torch>=2.13,<2.15",
|
"torch>=2.13,<2.15",
|
||||||
|
"turbohtml~=1.10.0",
|
||||||
"watchfiles>=1.2",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
"whoosh-compat[tantivy]==0.3",
|
"whoosh-compat[tantivy]==0.3",
|
||||||
@@ -139,7 +136,6 @@ typing = [
|
|||||||
"mypy",
|
"mypy",
|
||||||
"mypy-baseline",
|
"mypy-baseline",
|
||||||
"pyrefly",
|
"pyrefly",
|
||||||
"types-bleach",
|
|
||||||
"types-channels",
|
"types-channels",
|
||||||
"types-colorama",
|
"types-colorama",
|
||||||
"types-dateparser",
|
"types-dateparser",
|
||||||
@@ -154,6 +150,7 @@ typing = [
|
|||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
required-version = ">=0.9.0"
|
required-version = ">=0.9.0"
|
||||||
|
prerelease = "disallow"
|
||||||
environments = [
|
environments = [
|
||||||
"sys_platform == 'darwin'",
|
"sys_platform == 'darwin'",
|
||||||
"sys_platform == 'linux'",
|
"sys_platform == 'linux'",
|
||||||
|
|||||||
+70
-56
@@ -5,14 +5,14 @@
|
|||||||
<trans-unit id="ngb.alert.close" datatype="html">
|
<trans-unit id="ngb.alert.close" datatype="html">
|
||||||
<source>Close</source>
|
<source>Close</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/alert/alert.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/alert/alert.ts</context>
|
||||||
<context context-type="linenumber">39</context>
|
<context context-type="linenumber">39</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
||||||
<source> Slide <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> </source>
|
<source> Slide <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> </source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">129</context>
|
<context context-type="linenumber">129</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
||||||
@@ -20,219 +20,219 @@
|
|||||||
<trans-unit id="ngb.carousel.previous" datatype="html">
|
<trans-unit id="ngb.carousel.previous" datatype="html">
|
||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">147,148</context>
|
<context context-type="linenumber">147,148</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.carousel.next" datatype="html">
|
<trans-unit id="ngb.carousel.next" datatype="html">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">187</context>
|
<context context-type="linenumber">187</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
||||||
<source>Select month</source>
|
<source>Select month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
||||||
<source>Select year</source>
|
<source>Select year</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
||||||
<source>Previous month</source>
|
<source>Previous month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">74,76</context>
|
<context context-type="linenumber">74,76</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">103</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
||||||
<source>Next month</source>
|
<source>Next month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">103</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">103</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.first" datatype="html">
|
<trans-unit id="ngb.pagination.first" datatype="html">
|
||||||
<source>««</source>
|
<source>««</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">297</context>
|
<context context-type="linenumber">297</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.previous" datatype="html">
|
<trans-unit id="ngb.pagination.previous" datatype="html">
|
||||||
<source>«</source>
|
<source>«</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">322</context>
|
<context context-type="linenumber">322</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.next" datatype="html">
|
<trans-unit id="ngb.pagination.next" datatype="html">
|
||||||
<source>»</source>
|
<source>»</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">358</context>
|
<context context-type="linenumber">358</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.last" datatype="html">
|
<trans-unit id="ngb.pagination.last" datatype="html">
|
||||||
<source>»»</source>
|
<source>»»</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">396</context>
|
<context context-type="linenumber">396</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
||||||
<source>First</source>
|
<source>First</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">414,415</context>
|
<context context-type="linenumber">414,415</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">444,445</context>
|
<context context-type="linenumber">444,445</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">479,480</context>
|
<context context-type="linenumber">479,480</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
||||||
<source>Last</source>
|
<source>Last</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">506</context>
|
<context context-type="linenumber">506</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.progressbar.value" datatype="html">
|
<trans-unit id="ngb.progressbar.value" datatype="html">
|
||||||
<source><x id="INTERPOLATION"/></source>
|
<source><x id="INTERPOLATION"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/progressbar/progressbar.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/progressbar/progressbar.ts</context>
|
||||||
<context context-type="linenumber">40</context>
|
<context context-type="linenumber">40</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
||||||
<source>HH</source>
|
<source>HH</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">256,259</context>
|
<context context-type="linenumber">256,259</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
||||||
<source>Hours</source>
|
<source>Hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">293,295</context>
|
<context context-type="linenumber">293,295</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
||||||
<source>MM</source>
|
<source>MM</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">322</context>
|
<context context-type="linenumber">322</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
||||||
<source>Minutes</source>
|
<source>Minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">360</context>
|
<context context-type="linenumber">360</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
||||||
<source>Increment hours</source>
|
<source>Increment hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">397,399</context>
|
<context context-type="linenumber">397,399</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
||||||
<source>Decrement hours</source>
|
<source>Decrement hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">435,438</context>
|
<context context-type="linenumber">435,438</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
||||||
<source>Increment minutes</source>
|
<source>Increment minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
||||||
<source>Decrement minutes</source>
|
<source>Decrement minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
||||||
<source>SS</source>
|
<source>SS</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
||||||
<source>Seconds</source>
|
<source>Seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
||||||
<source>Increment seconds</source>
|
<source>Increment seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
||||||
<source>Decrement seconds</source>
|
<source>Decrement seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
||||||
<source><x id="INTERPOLATION"/></source>
|
<source><x id="INTERPOLATION"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
||||||
<source>Close</source>
|
<source>Close</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.6_@angular+core@22.1.6_@angular+_0f0da7e9cfc35b268da39b83c54e3258/node_modules/src/toast/toast.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.7_@angular+core@22.1.7_@angular+_bdd9adf606c7b6a0170d9f35c5973e0f/node_modules/src/toast/toast.ts</context>
|
||||||
<context context-type="linenumber">110,112</context>
|
<context context-type="linenumber">110,112</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -9745,7 +9745,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">348</context>
|
<context context-type="linenumber">351</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
||||||
@@ -9760,7 +9760,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">341</context>
|
<context context-type="linenumber">344</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
||||||
@@ -10016,56 +10016,56 @@
|
|||||||
<source>Reset filters / selection</source>
|
<source>Reset filters / selection</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">329</context>
|
<context context-type="linenumber">332</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4135055128446167640" datatype="html">
|
<trans-unit id="4135055128446167640" datatype="html">
|
||||||
<source>Open first [selected] document</source>
|
<source>Open first [selected] document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">357</context>
|
<context context-type="linenumber">360</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3629960544875360046" datatype="html">
|
<trans-unit id="3629960544875360046" datatype="html">
|
||||||
<source>Previous page</source>
|
<source>Previous page</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">373</context>
|
<context context-type="linenumber">376</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3337301694210287595" datatype="html">
|
<trans-unit id="3337301694210287595" datatype="html">
|
||||||
<source>Next page</source>
|
<source>Next page</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">385</context>
|
<context context-type="linenumber">388</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2155249406916744630" datatype="html">
|
<trans-unit id="2155249406916744630" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" saved successfully.</source>
|
<source>View "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" saved successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">419</context>
|
<context context-type="linenumber">422</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4646273665293421938" datatype="html">
|
<trans-unit id="4646273665293421938" datatype="html">
|
||||||
<source>Failed to save view "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>".</source>
|
<source>Failed to save view "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">425</context>
|
<context context-type="linenumber">428</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6837554170707123455" datatype="html">
|
<trans-unit id="6837554170707123455" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully.</source>
|
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">494</context>
|
<context context-type="linenumber">497</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6028096992841030074" datatype="html">
|
<trans-unit id="6028096992841030074" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully, but could not update visibility settings.</source>
|
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully, but could not update visibility settings.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">500</context>
|
<context context-type="linenumber">503</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="739880801667335279" datatype="html">
|
<trans-unit id="739880801667335279" datatype="html">
|
||||||
@@ -12018,81 +12018,95 @@
|
|||||||
<context context-type="linenumber">351</context>
|
<context context-type="linenumber">351</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="861068592166833023" datatype="html">
|
||||||
|
<source>LLM Embedding API Key</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
|
<context context-type="linenumber">358</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="2929108042259892948" datatype="html">
|
||||||
|
<source>Used for embeddings when set, otherwise LLM API key is used.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
|
<context context-type="linenumber">360</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="3554114880473286122" datatype="html">
|
<trans-unit id="3554114880473286122" datatype="html">
|
||||||
<source>LLM Embedding Endpoint</source>
|
<source>LLM Embedding Endpoint</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">358</context>
|
<context context-type="linenumber">366</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1044242175651289991" datatype="html">
|
<trans-unit id="1044242175651289991" datatype="html">
|
||||||
<source>LLM Embedding Chunk Size</source>
|
<source>LLM Embedding Chunk Size</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">365</context>
|
<context context-type="linenumber">373</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7218245223139363113" datatype="html">
|
<trans-unit id="7218245223139363113" datatype="html">
|
||||||
<source>LLM Context Size</source>
|
<source>LLM Context Size</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">372</context>
|
<context context-type="linenumber">380</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4234495692726214397" datatype="html">
|
<trans-unit id="4234495692726214397" datatype="html">
|
||||||
<source>LLM Backend</source>
|
<source>LLM Backend</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">379</context>
|
<context context-type="linenumber">387</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7935234833834000002" datatype="html">
|
<trans-unit id="7935234833834000002" datatype="html">
|
||||||
<source>LLM Model</source>
|
<source>LLM Model</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">387</context>
|
<context context-type="linenumber">395</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1980550530387803165" datatype="html">
|
<trans-unit id="1980550530387803165" datatype="html">
|
||||||
<source>LLM API Key</source>
|
<source>LLM API Key</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">394</context>
|
<context context-type="linenumber">402</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6126617860376156501" datatype="html">
|
<trans-unit id="6126617860376156501" datatype="html">
|
||||||
<source>LLM Endpoint</source>
|
<source>LLM Endpoint</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">401</context>
|
<context context-type="linenumber">409</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6572826277249350975" datatype="html">
|
<trans-unit id="6572826277249350975" datatype="html">
|
||||||
<source>LLM Output Language</source>
|
<source>LLM Output Language</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">408</context>
|
<context context-type="linenumber">416</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3284403507172415792" datatype="html">
|
<trans-unit id="3284403507172415792" datatype="html">
|
||||||
<source>Language to use for generated AI suggestions. When unset, AI suggestions use the user's display language if explicitly set.</source>
|
<source>Language to use for generated AI suggestions. When unset, AI suggestions use the user's display language if explicitly set.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">412</context>
|
<context context-type="linenumber">420</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4493921125434706859" datatype="html">
|
<trans-unit id="4493921125434706859" datatype="html">
|
||||||
<source>LLM Request Timeout</source>
|
<source>LLM Request Timeout</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">416</context>
|
<context context-type="linenumber">424</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="483994032066441287" datatype="html">
|
<trans-unit id="483994032066441287" datatype="html">
|
||||||
<source>Timeout in seconds for LLM requests.</source>
|
<source>Timeout in seconds for LLM requests.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">420</context>
|
<context context-type="linenumber">428</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1055686627716339120" datatype="html">
|
<trans-unit id="1055686627716339120" datatype="html">
|
||||||
|
|||||||
+15
-15
@@ -15,16 +15,16 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.6",
|
"@angular/cdk": "^22.1.7",
|
||||||
"@angular/common": "~22.1.6",
|
"@angular/common": "~22.1.7",
|
||||||
"@angular/compiler": "~22.1.6",
|
"@angular/compiler": "~22.1.7",
|
||||||
"@angular/core": "~22.1.6",
|
"@angular/core": "~22.1.7",
|
||||||
"@angular/forms": "~22.1.6",
|
"@angular/forms": "~22.1.7",
|
||||||
"@angular/localize": "~22.1.6",
|
"@angular/localize": "~22.1.7",
|
||||||
"@angular/platform-browser": "~22.1.6",
|
"@angular/platform-browser": "~22.1.7",
|
||||||
"@angular/router": "~22.1.6",
|
"@angular/router": "~22.1.7",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "~24.1.1",
|
"@ng-select/ng-select": "~24.1.2",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -54,20 +54,20 @@
|
|||||||
"@angular-eslint/template-parser": "22.5.0",
|
"@angular-eslint/template-parser": "22.5.0",
|
||||||
"@angular/build": "22.1.8",
|
"@angular/build": "22.1.8",
|
||||||
"@angular/cli": "22.1.8",
|
"@angular/cli": "22.1.8",
|
||||||
"@angular/compiler-cli": "~22.1.6",
|
"@angular/compiler-cli": "~22.1.7",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.5.0",
|
"@types/node": "^26.6.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
||||||
"@typescript-eslint/parser": "^8.70.0",
|
"@typescript-eslint/parser": "^8.70.0",
|
||||||
"@typescript-eslint/utils": "^8.70.0",
|
"@typescript-eslint/utils": "^8.70.0",
|
||||||
"eslint": "^10.10.0",
|
"eslint": "^10.11.0",
|
||||||
"jest": "30.5.1",
|
"jest": "30.5.2",
|
||||||
"jest-environment-jsdom": "^30.5.1",
|
"jest-environment-jsdom": "^30.5.2",
|
||||||
"jest-junit": "^17.0.0",
|
"jest-junit": "^17.0.0",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"jest-preset-angular": "^17.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.8",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.2",
|
"ts-node": "~10.9.2",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
|
|||||||
Generated
+531
-518
File diff suppressed because it is too large
Load Diff
@@ -154,11 +154,29 @@
|
|||||||
& section {
|
& section {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
text-align: initial;
|
text-align: initial;
|
||||||
|
pointer-events: auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton):not(.hasBorder)
|
||||||
|
> a:hover {
|
||||||
|
opacity: 0.2;
|
||||||
|
background-color: rgb(255 255 0);
|
||||||
|
}
|
||||||
|
|
||||||
& .annotationTextContent {
|
& .annotationTextContent {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .textLayer.selecting ~ .annotationLayer section {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { SimpleChange } from '@angular/core'
|
import { SimpleChange } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||||
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
|
import {
|
||||||
|
LinkTarget,
|
||||||
|
PDFSinglePageViewer,
|
||||||
|
PDFViewer,
|
||||||
|
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||||
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
||||||
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
||||||
|
|
||||||
@@ -58,6 +62,16 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
|
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens external links in a new tab', () => {
|
||||||
|
const linkService = (component as any).linkService
|
||||||
|
expect(linkService.options).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
externalLinkTarget: LinkTarget.BLANK,
|
||||||
|
externalLinkRel: 'noopener noreferrer nofollow',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves the worker source relative to the document base URI', async () => {
|
it('resolves the worker source relative to the document base URI', async () => {
|
||||||
setBaseHref('/paperless/')
|
setBaseHref('/paperless/')
|
||||||
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||||
import {
|
import {
|
||||||
EventBus,
|
EventBus,
|
||||||
|
LinkTarget,
|
||||||
PDFFindController,
|
PDFFindController,
|
||||||
PDFLinkService,
|
PDFLinkService,
|
||||||
PDFSinglePageViewer,
|
PDFSinglePageViewer,
|
||||||
@@ -75,7 +76,11 @@ export class PngxPdfViewerComponent
|
|||||||
private lastViewerPage?: number
|
private lastViewerPage?: number
|
||||||
|
|
||||||
private readonly eventBus = new EventBus()
|
private readonly eventBus = new EventBus()
|
||||||
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
|
private readonly linkService = new PDFLinkService({
|
||||||
|
eventBus: this.eventBus,
|
||||||
|
externalLinkTarget: LinkTarget.BLANK,
|
||||||
|
externalLinkRel: 'noopener noreferrer nofollow',
|
||||||
|
})
|
||||||
private readonly findController = new PDFFindController({
|
private readonly findController = new PDFFindController({
|
||||||
eventBus: this.eventBus,
|
eventBus: this.eventBus,
|
||||||
linkService: this.linkService,
|
linkService: this.linkService,
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ describe('DocumentListComponent', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should stop reloading on document deleted after destroy', () => {
|
||||||
|
const reloadSpy = jest.spyOn(documentListService, 'reload')
|
||||||
|
const documentDeletedSubject = new Subject<boolean>()
|
||||||
|
jest
|
||||||
|
.spyOn(websocketStatusService, 'onDocumentDeleted')
|
||||||
|
.mockReturnValue(documentDeletedSubject)
|
||||||
|
fixture.detectChanges()
|
||||||
|
fixture.destroy()
|
||||||
|
reloadSpy.mockClear()
|
||||||
|
documentDeletedSubject.next(true)
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should show score sort fields on fulltext queries', () => {
|
it('should show score sort fields on fulltext queries', () => {
|
||||||
documentListService.setFilterRules([
|
documentListService.setFilterRules([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -270,7 +270,10 @@ export class DocumentListComponent
|
|||||||
this.list.reload()
|
this.list.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
|
this.websocketStatusService
|
||||||
|
.onDocumentDeleted()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
this.list.reload()
|
this.list.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -353,6 +353,14 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
||||||
category: ConfigCategory.AI,
|
category: ConfigCategory.AI,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'llm_embedding_api_key',
|
||||||
|
title: $localize`LLM Embedding API Key`,
|
||||||
|
type: ConfigOptionType.Password,
|
||||||
|
note: $localize`Used for embeddings when set, otherwise LLM API key is used.`,
|
||||||
|
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_API_KEY',
|
||||||
|
category: ConfigCategory.AI,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'llm_embedding_endpoint',
|
key: 'llm_embedding_endpoint',
|
||||||
title: $localize`LLM Embedding Endpoint`,
|
title: $localize`LLM Embedding Endpoint`,
|
||||||
@@ -457,6 +465,7 @@ export interface PaperlessConfig extends ObjectWithId {
|
|||||||
ai_enabled: boolean
|
ai_enabled: boolean
|
||||||
llm_embedding_backend: string
|
llm_embedding_backend: string
|
||||||
llm_embedding_model: string
|
llm_embedding_model: string
|
||||||
|
llm_embedding_api_key: string
|
||||||
llm_embedding_endpoint: string
|
llm_embedding_endpoint: string
|
||||||
llm_embedding_chunk_size: number
|
llm_embedding_chunk_size: number
|
||||||
llm_context_size: number
|
llm_context_size: number
|
||||||
|
|||||||
@@ -25,10 +25,20 @@ export class PDFFindController {
|
|||||||
onIsPageVisible?: () => boolean
|
onIsPageVisible?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LinkTarget = {
|
||||||
|
NONE: 0,
|
||||||
|
SELF: 1,
|
||||||
|
BLANK: 2,
|
||||||
|
PARENT: 3,
|
||||||
|
TOP: 4,
|
||||||
|
}
|
||||||
|
|
||||||
export class PDFLinkService {
|
export class PDFLinkService {
|
||||||
private document?: unknown
|
private document?: unknown
|
||||||
private viewer?: unknown
|
private viewer?: unknown
|
||||||
|
|
||||||
|
constructor(readonly options: Record<string, unknown> = {}) {}
|
||||||
|
|
||||||
setDocument(document: unknown): void {
|
setDocument(document: unknown): void {
|
||||||
this.document = document
|
this.document = document
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,10 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from pytest_django.fixtures import Settings
|
from pytest_django.fixtures import Settings
|
||||||
from pytest_mock import MockerFixture
|
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from paperless_testing.dirs import PaperlessDirs
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
from paperless_testing.fakes.progress import FakeProgressManager
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
from paperless_testing.outbound import DialRecorder
|
|
||||||
from paperless_testing.outbound import FakeDNS
|
|
||||||
from paperless_testing.outbound import LocalHTTPServer
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
@@ -153,41 +149,3 @@ def fake_progress_manager(
|
|||||||
|
|
||||||
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
|
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
|
||||||
return FakeProgressManager
|
return FakeProgressManager
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def local_http_server() -> Generator[LocalHTTPServer, None, None]:
|
|
||||||
"""A recording HTTP server on 127.0.0.1, for outbound connection tests."""
|
|
||||||
from paperless_testing.outbound import running_http_server
|
|
||||||
|
|
||||||
with running_http_server() as server:
|
|
||||||
yield server
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_dns(mocker: MockerFixture) -> FakeDNS:
|
|
||||||
"""Per-hostname answers for the outbound guard's resolver hooks."""
|
|
||||||
from paperless_testing.outbound import install_fake_dns
|
|
||||||
|
|
||||||
return install_fake_dns(mocker)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def dial_recorder(mocker: MockerFixture) -> DialRecorder:
|
|
||||||
"""Records which addresses the outbound guard actually dialled."""
|
|
||||||
from paperless_testing.outbound import install_dial_recorder
|
|
||||||
|
|
||||||
return install_dial_recorder(mocker)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def every_address_is_public(mocker: MockerFixture) -> None:
|
|
||||||
"""Disable the outbound guard's address policy: every address passes.
|
|
||||||
|
|
||||||
For tests that are not themselves exercising which addresses the guard
|
|
||||||
accepts, so loopback and other private addresses dial just like a
|
|
||||||
public one.
|
|
||||||
"""
|
|
||||||
from paperless_testing.outbound import allow_all_addresses
|
|
||||||
|
|
||||||
allow_all_addresses(mocker)
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class DocumentsConfig(AppConfig):
|
|||||||
document_consumption_finished.connect(set_document_type)
|
document_consumption_finished.connect(set_document_type)
|
||||||
document_consumption_finished.connect(set_tags)
|
document_consumption_finished.connect(set_tags)
|
||||||
document_consumption_finished.connect(set_storage_path)
|
document_consumption_finished.connect(set_storage_path)
|
||||||
document_consumption_finished.connect(add_to_index)
|
|
||||||
document_consumption_finished.connect(run_workflows_added)
|
document_consumption_finished.connect(run_workflows_added)
|
||||||
document_consumption_finished.connect(add_to_index)
|
document_consumption_finished.connect(add_to_index)
|
||||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||||
|
|||||||
@@ -857,8 +857,9 @@ class ConsumerPlugin(
|
|||||||
self.log.debug(f"Creation date from parse_date: {create_date}")
|
self.log.debug(f"Creation date from parse_date: {create_date}")
|
||||||
else:
|
else:
|
||||||
stats = Path(self.input_doc.original_file).stat()
|
stats = Path(self.input_doc.original_file).stat()
|
||||||
create_date = timezone.make_aware(
|
create_date = datetime.datetime.fromtimestamp(
|
||||||
datetime.datetime.fromtimestamp(stats.st_mtime),
|
stats.st_mtime,
|
||||||
|
tz=timezone.get_current_timezone(),
|
||||||
)
|
)
|
||||||
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
||||||
|
|
||||||
|
|||||||
@@ -1079,6 +1079,16 @@ class DocumentSerializer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_page_count(self, obj) -> int | None:
|
def get_page_count(self, obj) -> int | None:
|
||||||
|
# Like content versions get their own page count from the newest version,
|
||||||
|
# use the prefetched versions cache to avoid an extra query
|
||||||
|
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
|
||||||
|
prefetched_versions = (
|
||||||
|
prefetched_cache.get("versions")
|
||||||
|
if isinstance(prefetched_cache, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if obj.root_document_id is None and prefetched_versions:
|
||||||
|
return sort_versions_newest_first(prefetched_versions)[0].page_count
|
||||||
return obj.page_count
|
return obj.page_count
|
||||||
|
|
||||||
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
|
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from documents.permissions import get_objects_for_user_owner_aware
|
|||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.templating.utils import convert_format_str_to_template_format
|
from documents.templating.utils import convert_format_str_to_template_format
|
||||||
from documents.utils import compute_checksum
|
from documents.utils import compute_checksum
|
||||||
|
from documents.utils import copy_file_with_basic_stats
|
||||||
from documents.workflows.actions import build_workflow_action_context
|
from documents.workflows.actions import build_workflow_action_context
|
||||||
from documents.workflows.actions import execute_email_action
|
from documents.workflows.actions import execute_email_action
|
||||||
from documents.workflows.actions import execute_move_to_trash_action
|
from documents.workflows.actions import execute_move_to_trash_action
|
||||||
@@ -363,7 +364,11 @@ def cleanup_document_deletion(sender, instance, **kwargs) -> None:
|
|||||||
|
|
||||||
logger.debug(f"Moving {instance.source_path} to trash at {new_file_path}")
|
logger.debug(f"Moving {instance.source_path} to trash at {new_file_path}")
|
||||||
try:
|
try:
|
||||||
shutil.move(instance.source_path, new_file_path)
|
shutil.move(
|
||||||
|
instance.source_path,
|
||||||
|
new_file_path,
|
||||||
|
copy_function=copy_file_with_basic_stats,
|
||||||
|
)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to move {instance.source_path} to trash at "
|
f"Failed to move {instance.source_path} to trash at "
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
"ai_enabled": None,
|
"ai_enabled": None,
|
||||||
"llm_embedding_backend": None,
|
"llm_embedding_backend": None,
|
||||||
"llm_embedding_model": None,
|
"llm_embedding_model": None,
|
||||||
|
"llm_embedding_api_key": None,
|
||||||
"llm_embedding_endpoint": None,
|
"llm_embedding_endpoint": None,
|
||||||
"llm_embedding_chunk_size": None,
|
"llm_embedding_chunk_size": None,
|
||||||
"llm_context_size": None,
|
"llm_context_size": None,
|
||||||
@@ -922,6 +923,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||||
self.assertEqual(ApplicationConfiguration.objects.count(), 1)
|
self.assertEqual(ApplicationConfiguration.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_update_llm_embedding_api_key(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing config with llm_embedding_api_key specified
|
||||||
|
WHEN:
|
||||||
|
- API to update llm_embedding_api_key is called with all *s
|
||||||
|
- API to update llm_embedding_api_key is called with empty string
|
||||||
|
THEN:
|
||||||
|
- llm_embedding_api_key is unchanged
|
||||||
|
- llm_embedding_api_key is set to None
|
||||||
|
"""
|
||||||
|
config = ApplicationConfiguration.objects.first()
|
||||||
|
assert config is not None
|
||||||
|
config.llm_embedding_api_key = "1234567890"
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
# Test with all *
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"llm_embedding_api_key": "*" * 32,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
config.refresh_from_db()
|
||||||
|
self.assertEqual(config.llm_embedding_api_key, "1234567890")
|
||||||
|
# Test with empty string
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"llm_embedding_api_key": "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
config.refresh_from_db()
|
||||||
|
self.assertEqual(config.llm_embedding_api_key, None)
|
||||||
|
|
||||||
def test_update_llm_api_key(self) -> None:
|
def test_update_llm_api_key(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -166,7 +166,15 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
response.close()
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response["Content-Type"], "application/zip")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(read_streaming_response(response))) as zipf:
|
||||||
|
self.assertEqual(zipf.infolist()[0].compress_type, zipfile.ZIP_LZMA)
|
||||||
|
|
||||||
|
with self.doc2.source_file as f:
|
||||||
|
self.assertEqual(f.read(), zipf.read("2021-01-01 document A.pdf"))
|
||||||
|
|
||||||
@override_settings(FILENAME_FORMAT="{correspondent}/{title}")
|
@override_settings(FILENAME_FORMAT="{correspondent}/{title}")
|
||||||
def test_formatted_download_originals(self) -> None:
|
def test_formatted_download_originals(self) -> None:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from rest_framework.test import APITestCase
|
|||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
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
|
||||||
@@ -2525,7 +2526,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
WHEN:
|
WHEN:
|
||||||
- API to bulk edit documents is called
|
- API to bulk edit documents is called
|
||||||
THEN:
|
THEN:
|
||||||
- Audit log is created
|
- Audit log is created with the old and new correspondent
|
||||||
"""
|
"""
|
||||||
LogEntry.objects.all().delete()
|
LogEntry.objects.all().delete()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -2541,7 +2542,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
|
entry = LogEntry.objects.get_for_object(self.doc1).get()
|
||||||
|
self.assertEqual(entry.changes, {"correspondent": [None, self.c2.id]})
|
||||||
|
|
||||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
def test_bulk_edit_audit_log_enabled_tags(self) -> None:
|
def test_bulk_edit_audit_log_enabled_tags(self) -> None:
|
||||||
@@ -2549,16 +2551,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
GIVEN:
|
GIVEN:
|
||||||
- Audit log is enabled
|
- Audit log is enabled
|
||||||
WHEN:
|
WHEN:
|
||||||
- API to bulk edit tags is called
|
- API to bulk edit tags is called on an untagged document and a
|
||||||
|
document with several tags
|
||||||
THEN:
|
THEN:
|
||||||
- Audit log is created
|
- Audit log is created for each document with its full tag list
|
||||||
|
before and after the edit
|
||||||
"""
|
"""
|
||||||
LogEntry.objects.all().delete()
|
LogEntry.objects.all().delete()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"documents": [self.doc1.id],
|
"documents": [self.doc1.id, self.doc4.id],
|
||||||
"method": "modify_tags",
|
"method": "modify_tags",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"add_tags": [self.t1.id],
|
"add_tags": [self.t1.id],
|
||||||
@@ -2570,18 +2574,32 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
|
entry = LogEntry.objects.get_for_object(self.doc1).get()
|
||||||
|
self.assertEqual(entry.changes, {"tags": [[], [self.t1.id]]})
|
||||||
|
entry = LogEntry.objects.get_for_object(self.doc4).get()
|
||||||
|
self.assertEqual(
|
||||||
|
entry.changes,
|
||||||
|
{"tags": [[self.t1.id, self.t2.id], [self.t1.id]]},
|
||||||
|
)
|
||||||
|
|
||||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
def test_bulk_edit_audit_log_enabled_custom_fields(self) -> None:
|
def test_bulk_edit_audit_log_enabled_custom_fields(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Audit log is enabled
|
- Audit log is enabled
|
||||||
|
- A document with two custom fields
|
||||||
WHEN:
|
WHEN:
|
||||||
- API to bulk edit custom fields is called
|
- API to bulk edit custom fields is called to add a third
|
||||||
THEN:
|
THEN:
|
||||||
- Audit log is created
|
- Audit log is created with every custom field instance before and
|
||||||
|
after the edit
|
||||||
|
- Audit log is created for the new custom field instance
|
||||||
"""
|
"""
|
||||||
|
cf3 = CustomField.objects.create(name="cf3", data_type="string")
|
||||||
|
existing = [
|
||||||
|
CustomFieldInstance.objects.create(document=self.doc1, field=field)
|
||||||
|
for field in (self.cf2, cf3)
|
||||||
|
]
|
||||||
LogEntry.objects.all().delete()
|
LogEntry.objects.all().delete()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
@@ -2599,7 +2617,14 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)
|
added = CustomFieldInstance.objects.get(document=self.doc1, field=self.cf1)
|
||||||
|
existing_ids = [instance.id for instance in existing]
|
||||||
|
entry = LogEntry.objects.get_for_object(self.doc1).get()
|
||||||
|
self.assertEqual(
|
||||||
|
entry.changes,
|
||||||
|
{"custom_fields": [existing_ids, [*existing_ids, added.id]]},
|
||||||
|
)
|
||||||
|
self.assertEqual(LogEntry.objects.get_for_object(added).count(), 1)
|
||||||
|
|
||||||
def test_api_bulk_edit_with_bad_search_query_returns_400(self) -> None:
|
def test_api_bulk_edit_with_bad_search_query_returns_400(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from documents.models import Document
|
|||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
from documents.views import DocumentSelectionMixin
|
from documents.views import DocumentSelectionMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.http import read_streaming_response
|
from paperless_testing.http import read_streaming_response
|
||||||
from paperless_testing.permissions import grant_global
|
from paperless_testing.permissions import grant_global
|
||||||
@@ -821,6 +822,26 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(resp.data["content"], "v1-content")
|
self.assertEqual(resp.data["content"], "v1-content")
|
||||||
|
|
||||||
|
def test_page_count_resolves_to_latest_version(self) -> None:
|
||||||
|
root = DocumentFactory(page_count=2)
|
||||||
|
DocumentFactory(root_document=root, version_index=1, page_count=1)
|
||||||
|
unversioned = DocumentFactory(page_count=5)
|
||||||
|
|
||||||
|
resp = self.client.get("/api/documents/?fields=id,page_count")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
{doc["id"]: doc["page_count"] for doc in resp.data["results"]},
|
||||||
|
{root.id: 1, unversioned.id: 5},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = self.client.get(f"/api/documents/{root.id}/")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(resp.data["page_count"], 1)
|
||||||
|
|
||||||
|
resp = self.client.get(f"/api/documents/{root.id}/?version={root.id}")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(resp.data["page_count"], 2)
|
||||||
|
|
||||||
def _make_root_with_out_of_order_versions(self) -> tuple[Document, ...]:
|
def _make_root_with_out_of_order_versions(self) -> tuple[Document, ...]:
|
||||||
"""
|
"""
|
||||||
A root whose newest version has a *lower* id than an older one, which is
|
A root whose newest version has a *lower* id than an older one, which is
|
||||||
|
|||||||
@@ -64,16 +64,15 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
def setupSocialAccount(self) -> None:
|
def setupSocialAccount(self) -> SocialAccount:
|
||||||
SocialApp.objects.create(
|
SocialApp.objects.create(
|
||||||
name="Keycloak",
|
name="Keycloak",
|
||||||
provider="openid_connect",
|
provider="openid_connect",
|
||||||
provider_id="keycloak-test",
|
provider_id="keycloak-test",
|
||||||
)
|
)
|
||||||
self.user.socialaccount_set.add(
|
social_account = SocialAccount(uid="123456789", provider="keycloak-test")
|
||||||
SocialAccount(uid="123456789", provider="keycloak-test"),
|
self.user.socialaccount_set.add(social_account, bulk=False)
|
||||||
bulk=False,
|
return social_account
|
||||||
)
|
|
||||||
|
|
||||||
def test_get_profile(self) -> None:
|
def test_get_profile(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -111,19 +110,17 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Profile is returned with social accounts
|
- Profile is returned with social accounts
|
||||||
"""
|
"""
|
||||||
self.setupSocialAccount()
|
social_account = self.setupSocialAccount()
|
||||||
|
|
||||||
openid_provider = (
|
openid_provider = MockOpenIDConnectProvider(
|
||||||
MockOpenIDConnectProvider(
|
|
||||||
app=SocialApp.objects.get(provider_id="keycloak-test"),
|
app=SocialApp.objects.get(provider_id="keycloak-test"),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
mock_list_providers.return_value = [
|
mock_list_providers.return_value = [
|
||||||
openid_provider,
|
openid_provider,
|
||||||
]
|
]
|
||||||
mock_get_provider_account.return_value = MockOpenIDConnectProviderAccount(
|
mock_get_provider_account.return_value = MockOpenIDConnectProviderAccount(
|
||||||
mock_social_account_dict={
|
mock_social_account_dict={
|
||||||
"name": openid_provider[0].name,
|
"name": openid_provider.name,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,7 +132,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
response.data["social_accounts"],
|
response.data["social_accounts"],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": social_account.pk,
|
||||||
"provider": "keycloak-test",
|
"provider": "keycloak-test",
|
||||||
"name": "Keycloak",
|
"name": "Keycloak",
|
||||||
},
|
},
|
||||||
@@ -152,7 +149,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Profile is returned with "Unknown App" as name
|
- Profile is returned with "Unknown App" as name
|
||||||
"""
|
"""
|
||||||
self.setupSocialAccount()
|
social_account = self.setupSocialAccount()
|
||||||
|
|
||||||
# Remove the social app
|
# Remove the social app
|
||||||
SocialApp.objects.get(provider_id="keycloak-test").delete()
|
SocialApp.objects.get(provider_id="keycloak-test").delete()
|
||||||
@@ -165,7 +162,7 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
response.data["social_accounts"],
|
response.data["social_accounts"],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": social_account.pk,
|
||||||
"provider": "keycloak-test",
|
"provider": "keycloak-test",
|
||||||
"name": "Unknown App",
|
"name": "Unknown App",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -677,7 +677,8 @@ class TestExportImport(
|
|||||||
THEN:
|
THEN:
|
||||||
- Error is raised
|
- Error is raised
|
||||||
"""
|
"""
|
||||||
args = ["document_exporter", "/tmp/foo/bar"]
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
args = ["document_exporter", str(Path(tmp_dir) / "does-not-exist")]
|
||||||
|
|
||||||
with self.assertRaises(CommandError) as e:
|
with self.assertRaises(CommandError) as e:
|
||||||
call_command(*args, skip_checks=True)
|
call_command(*args, skip_checks=True)
|
||||||
|
|||||||
@@ -123,14 +123,14 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
- Output contains clickable links to the documents instead of titles
|
- Output contains clickable links to the documents instead of titles
|
||||||
"""
|
"""
|
||||||
# Content similarity is 86.667
|
# Content similarity is 86.667
|
||||||
Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
checksum="BEEFCAFE",
|
checksum="BEEFCAFE",
|
||||||
title="A",
|
title="A",
|
||||||
content="first document scanned by bob",
|
content="first document scanned by bob",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
filename="test.pdf",
|
filename="test.pdf",
|
||||||
)
|
)
|
||||||
Document.objects.create(
|
doc2 = Document.objects.create(
|
||||||
checksum="DEADBEAF",
|
checksum="DEADBEAF",
|
||||||
title="A",
|
title="A",
|
||||||
content="first document scanned by alice",
|
content="first document scanned by alice",
|
||||||
@@ -145,8 +145,8 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
"http://localhost:8000",
|
"http://localhost:8000",
|
||||||
)
|
)
|
||||||
self.assertIn("Found 1 matching pair(s)", stdout)
|
self.assertIn("Found 1 matching pair(s)", stdout)
|
||||||
self.assertIn("http://localhost:8000/documents/1/details", stdout)
|
self.assertIn(f"http://localhost:8000/documents/{doc1.pk}/details", stdout)
|
||||||
self.assertIn("http://localhost:8000/documents/2/details", stdout)
|
self.assertIn(f"http://localhost:8000/documents/{doc2.pk}/details", stdout)
|
||||||
|
|
||||||
def test_with_3_matches(self) -> None:
|
def test_with_3_matches(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -198,14 +198,14 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
- Documents 1 and 2 remain
|
- Documents 1 and 2 remain
|
||||||
"""
|
"""
|
||||||
# Content similarity is 86.667
|
# Content similarity is 86.667
|
||||||
Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
checksum="BEEFCAFE",
|
checksum="BEEFCAFE",
|
||||||
title="A",
|
title="A",
|
||||||
content="first document scanned by bob",
|
content="first document scanned by bob",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
filename="test.pdf",
|
filename="test.pdf",
|
||||||
)
|
)
|
||||||
Document.objects.create(
|
doc2 = Document.objects.create(
|
||||||
checksum="DEADBEAF",
|
checksum="DEADBEAF",
|
||||||
title="A",
|
title="A",
|
||||||
content="second document scanned by alice",
|
content="second document scanned by alice",
|
||||||
@@ -235,8 +235,8 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
self.assertIn("Deleting 1 document(s)", stdout)
|
self.assertIn("Deleting 1 document(s)", stdout)
|
||||||
|
|
||||||
self.assertEqual(Document.objects.count(), 2)
|
self.assertEqual(Document.objects.count(), 2)
|
||||||
self.assertIsNotNone(Document.objects.get(pk=1))
|
self.assertIsNotNone(Document.objects.get(pk=doc1.pk))
|
||||||
self.assertIsNotNone(Document.objects.get(pk=2))
|
self.assertIsNotNone(Document.objects.get(pk=doc2.pk))
|
||||||
|
|
||||||
def test_document_deletion_cancelled(self) -> None:
|
def test_document_deletion_cancelled(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from paperless_testing.dirs import DirectoriesMixin
|
|||||||
class TestManageSuperUser(DirectoriesMixin, TestCase):
|
class TestManageSuperUser(DirectoriesMixin, TestCase):
|
||||||
def call_command(self, environ):
|
def call_command(self, environ):
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
with mock.patch.dict(os.environ, environ):
|
with mock.patch.dict(os.environ, environ, clear=True):
|
||||||
call_command(
|
call_command(
|
||||||
"manage_superuser",
|
"manage_superuser",
|
||||||
"--no-color",
|
"--no-color",
|
||||||
|
|||||||
@@ -430,6 +430,53 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
|||||||
) # version-only grant must not substitute for root permission
|
) # version-only grant must not substitute for root permission
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestDocumentOperationPermissionChecksRootDocument:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("endpoint", "payload"),
|
||||||
|
[
|
||||||
|
pytest.param("/api/documents/merge/", {}, id="merge"),
|
||||||
|
pytest.param("/api/documents/rotate/", {"degrees": 90}, id="rotate"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("version_owner", ["none", "requester"])
|
||||||
|
def test_version_operation_acts_on_root(
|
||||||
|
self,
|
||||||
|
rest_api_client: APIClient,
|
||||||
|
endpoint: str,
|
||||||
|
payload: dict,
|
||||||
|
version_owner: str,
|
||||||
|
) -> None:
|
||||||
|
owner = UserFactory(username="owner")
|
||||||
|
requester = UserFactory(username="requester")
|
||||||
|
grant_global(requester, "change_document")
|
||||||
|
grant_global(requester, "add_document")
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
root = DocumentFactory(owner=owner)
|
||||||
|
# A version whose owner went stale, e.g. created before the root changed hands
|
||||||
|
version = DocumentFactory(
|
||||||
|
owner=requester if version_owner == "requester" else None,
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("documents.views.bulk_edit.merge") as mock_merge,
|
||||||
|
patch("documents.views.bulk_edit.rotate") as mock_rotate,
|
||||||
|
):
|
||||||
|
mock_merge.__name__ = "merge"
|
||||||
|
mock_rotate.__name__ = "rotate"
|
||||||
|
response = rest_api_client.post(
|
||||||
|
endpoint,
|
||||||
|
{"documents": [version.pk], **payload},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
mock_merge.assert_not_called()
|
||||||
|
mock_rotate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.usefixtures("_search_index")
|
@pytest.mark.usefixtures("_search_index")
|
||||||
class TestTrashRestorePermissionBoundary:
|
class TestTrashRestorePermissionBoundary:
|
||||||
|
|||||||
@@ -339,15 +339,6 @@ class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.document.archive_checksum = ""
|
self.document.archive_checksum = ""
|
||||||
self.document.save()
|
self.document.save()
|
||||||
self.addCleanup(
|
|
||||||
setattr,
|
|
||||||
settings,
|
|
||||||
"SHARE_LINK_BUNDLE_DIR",
|
|
||||||
settings.SHARE_LINK_BUNDLE_DIR,
|
|
||||||
)
|
|
||||||
settings.SHARE_LINK_BUNDLE_DIR = (
|
|
||||||
Path(settings.MEDIA_ROOT) / "documents" / "share_link_bundles"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
|
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
|
||||||
if archive:
|
if archive:
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from documents.models import WorkflowAction
|
|||||||
from documents.sanity_checker import SanityCheckFailedException
|
from documents.sanity_checker import SanityCheckFailedException
|
||||||
from documents.sanity_checker import SanityCheckMessages
|
from documents.sanity_checker import SanityCheckMessages
|
||||||
from documents.tests.helpers import dummy_preprocess
|
from documents.tests.helpers import dummy_preprocess
|
||||||
from paperless_ai.exceptions import LLMBlockedError
|
|
||||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
|
||||||
@@ -556,37 +555,3 @@ class TestApplyAISuggestionsTask(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
apply_suggestions.assert_not_called()
|
apply_suggestions.assert_not_called()
|
||||||
self.assertIn("no longer exists", "".join(cm.output))
|
self.assertIn("no longer exists", "".join(cm.output))
|
||||||
|
|
||||||
@override_settings(AI_ENABLED=True)
|
|
||||||
def test_blocked_request_fails_without_retry(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- AI enabled and a document with content
|
|
||||||
- The AI classification call blocked by the outbound request policy
|
|
||||||
WHEN:
|
|
||||||
- The task runs through Celery
|
|
||||||
THEN:
|
|
||||||
- The workflow code does not swallow the block
|
|
||||||
- The task fails with LLMBlockedError and is never retried
|
|
||||||
"""
|
|
||||||
with (
|
|
||||||
mock.patch(
|
|
||||||
"documents.workflows.ai.get_ai_document_classification",
|
|
||||||
side_effect=LLMBlockedError(
|
|
||||||
"AI backend request was blocked by the outbound request "
|
|
||||||
"policy: detail",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
mock.patch.object(
|
|
||||||
tasks.apply_ai_suggestions,
|
|
||||||
"retry",
|
|
||||||
wraps=tasks.apply_ai_suggestions.retry,
|
|
||||||
) as retry,
|
|
||||||
):
|
|
||||||
result = tasks.apply_ai_suggestions.apply(
|
|
||||||
args=(self.action.pk, self.doc.pk),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(result.failed())
|
|
||||||
self.assertIsInstance(result.result, LLMBlockedError)
|
|
||||||
retry.assert_not_called()
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from documents.models import Tag
|
|||||||
from documents.models import UiSettings
|
from documents.models import UiSettings
|
||||||
from documents.signals.handlers import update_llm_suggestions_cache
|
from documents.signals.handlers import update_llm_suggestions_cache
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless_ai.exceptions import LLMBlockedError
|
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
from paperless_ai.exceptions import LLMProviderError
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
@@ -771,48 +770,6 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
|
||||||
@override_settings(
|
|
||||||
AI_ENABLED=True,
|
|
||||||
LLM_BACKEND="openai-like",
|
|
||||||
)
|
|
||||||
def test_ai_suggestions_with_blocked_llm_request(
|
|
||||||
self,
|
|
||||||
mock_get_ai_classification,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An AI backend request blocked by the outbound request policy
|
|
||||||
WHEN:
|
|
||||||
- AI suggestions are requested
|
|
||||||
THEN:
|
|
||||||
- 502 is returned with a generic message and nothing is cached
|
|
||||||
"""
|
|
||||||
mock_get_ai_classification.side_effect = LLMBlockedError(
|
|
||||||
"AI backend request was blocked by the outbound request policy: detail",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_login(user=self.user)
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY)
|
|
||||||
self.assertEqual(
|
|
||||||
response.json(),
|
|
||||||
{
|
|
||||||
"ai": [
|
|
||||||
(
|
|
||||||
"AI backend request was blocked by the outbound request "
|
|
||||||
"policy. Check logs for details."
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertIsNone(
|
|
||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("documents.views.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -17,11 +19,11 @@ from django.test import override_settings
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
|
from httpx import ConnectError
|
||||||
from httpx import HTTPError
|
from httpx import HTTPError
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
from pytest_django.fixtures import Settings
|
from pytest_django.fixtures import Settings
|
||||||
from pytest_httpx import HTTPXMock
|
from pytest_httpx import HTTPXMock
|
||||||
from pytest_mock import MockerFixture
|
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -31,12 +33,8 @@ from documents.file_handling import generate_unique_filename
|
|||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.workflows.ai import apply_ai_suggestions_to_document
|
from documents.workflows.ai import apply_ai_suggestions_to_document
|
||||||
from documents.workflows.webhooks import send_webhook
|
from documents.workflows.webhooks import send_webhook
|
||||||
from paperless.network import OutboundRequestBlockedError
|
|
||||||
from paperless_ai.base_model import ClassificationSuggestions
|
from paperless_ai.base_model import ClassificationSuggestions
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
from paperless_testing.outbound import DialRecorder
|
|
||||||
from paperless_testing.outbound import FakeDNS
|
|
||||||
from paperless_testing.outbound import LocalHTTPServer
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
@@ -71,7 +69,9 @@ from paperless_mail.models import MailAccount
|
|||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
|
from paperless_testing.permissions import grant_global
|
||||||
from paperless_testing.permissions import grant_object
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
@@ -1061,6 +1061,41 @@ class TestWorkflows(
|
|||||||
self.assertEqual(doc.correspondent, self.c2)
|
self.assertEqual(doc.correspondent, self.c2)
|
||||||
self.assertEqual(doc.title, f"Doc created in {created.year}")
|
self.assertEqual(doc.title, f"Doc created in {created.year}")
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_search_index")
|
||||||
|
def test_document_added_workflow_indexes_final_title(self) -> None:
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
filter_filename="*sample*",
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
assign_title="Linked document",
|
||||||
|
assign_owner=self.user2,
|
||||||
|
)
|
||||||
|
link_field = CustomField.objects.create(
|
||||||
|
name="Related documents",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
action.assign_custom_fields.add(link_field)
|
||||||
|
workflow = Workflow.objects.create(name="Link workflow", order=0)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
|
||||||
|
doc = DocumentFactory.create()
|
||||||
|
document_consumption_finished.send(sender=self.__class__, document=doc)
|
||||||
|
|
||||||
|
self.assertTrue(doc.custom_fields.filter(field=link_field).exists())
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertEqual(doc.title, "Linked document")
|
||||||
|
|
||||||
|
grant_global(self.user2, "view_document")
|
||||||
|
self.client.force_authenticate(user=self.user2)
|
||||||
|
response = self.client.get("/api/documents/?title_search=linked")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
[result["id"] for result in response.data["results"]],
|
||||||
|
[doc.pk],
|
||||||
|
)
|
||||||
|
|
||||||
def test_document_added_no_match_filename(self) -> None:
|
def test_document_added_no_match_filename(self) -> None:
|
||||||
trigger = WorkflowTrigger.objects.create(
|
trigger = WorkflowTrigger.objects.create(
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
@@ -4424,18 +4459,18 @@ class TestWorkflows(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.remove_password")
|
@mock.patch("documents.bulk_edit.remove_password")
|
||||||
def test_password_removal_action_fails_without_correct_password(
|
def test_password_removal_action_skips_blank_and_whitespace_passwords(
|
||||||
self,
|
self,
|
||||||
mock_remove_password,
|
mock_remove_password,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Workflow password removal action
|
- Workflow password removal action
|
||||||
- No correct password provided
|
- Only blank and whitespace-only passwords configured
|
||||||
WHEN:
|
WHEN:
|
||||||
- Document updated triggering the workflow
|
- Document updated triggering the workflow
|
||||||
THEN:
|
THEN:
|
||||||
- Password removal is attempted for all passwords and fails
|
- Password removal is not attempted
|
||||||
"""
|
"""
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
title="Protected",
|
title="Protected",
|
||||||
@@ -4456,6 +4491,60 @@ class TestWorkflows(
|
|||||||
|
|
||||||
mock_remove_password.assert_not_called()
|
mock_remove_password.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.remove_password")
|
||||||
|
def test_password_removal_action_fails_without_correct_password(
|
||||||
|
self,
|
||||||
|
mock_remove_password,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Workflow password removal action
|
||||||
|
- No configured password is correct
|
||||||
|
WHEN:
|
||||||
|
- Document updated triggering the workflow
|
||||||
|
THEN:
|
||||||
|
- Password removal is attempted for every configured password and fails
|
||||||
|
"""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Protected",
|
||||||
|
checksum="pw-checksum-3",
|
||||||
|
)
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
||||||
|
passwords=["wrong", "also-wrong"],
|
||||||
|
)
|
||||||
|
workflow = Workflow.objects.create(name="Password workflow wrong passwords")
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
|
||||||
|
mock_remove_password.side_effect = ValueError("wrong password")
|
||||||
|
|
||||||
|
with self.assertLogs("paperless.workflows.actions", level="ERROR"):
|
||||||
|
run_workflows(trigger.type, doc)
|
||||||
|
|
||||||
|
assert mock_remove_password.call_count == 2
|
||||||
|
mock_remove_password.assert_has_calls(
|
||||||
|
[
|
||||||
|
mock.call(
|
||||||
|
[doc.id],
|
||||||
|
password="wrong",
|
||||||
|
update_document=True,
|
||||||
|
user=doc.owner,
|
||||||
|
source_paths_by_id=None,
|
||||||
|
),
|
||||||
|
mock.call(
|
||||||
|
[doc.id],
|
||||||
|
password="also-wrong",
|
||||||
|
update_document=True,
|
||||||
|
user=doc.owner,
|
||||||
|
source_paths_by_id=None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.remove_password")
|
@mock.patch("documents.bulk_edit.remove_password")
|
||||||
def test_password_removal_action_skips_without_passwords(
|
def test_password_removal_action_skips_without_passwords(
|
||||||
self,
|
self,
|
||||||
@@ -5071,6 +5160,25 @@ class TestWebhookSend:
|
|||||||
assert httpx_mock.get_request().headers["Content-Type"] == "application/json"
|
assert httpx_mock.get_request().headers["Content-Type"] == "application/json"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def resolve_to(monkeypatch: pytest.MonkeyPatch) -> Callable[[str], None]:
|
||||||
|
"""
|
||||||
|
Force DNS resolution to a specific IP for any hostname.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _set(ip: str) -> None:
|
||||||
|
def fake_getaddrinfo(
|
||||||
|
host: str,
|
||||||
|
*_args: object,
|
||||||
|
**_kwargs: object,
|
||||||
|
) -> list[tuple[Any, ...]]:
|
||||||
|
return [(socket.AF_INET, None, None, "", (ip, 0))]
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||||
|
|
||||||
|
return _set
|
||||||
|
|
||||||
|
|
||||||
class TestWebhookSecurity:
|
class TestWebhookSecurity:
|
||||||
def test_blocks_invalid_scheme_or_hostname(self, httpx_mock: HTTPXMock) -> None:
|
def test_blocks_invalid_scheme_or_hostname(self, httpx_mock: HTTPXMock) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -5120,145 +5228,60 @@ class TestWebhookSecurity:
|
|||||||
|
|
||||||
assert httpx_mock.get_request() is None
|
assert httpx_mock.get_request() is None
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"address",
|
|
||||||
[
|
|
||||||
pytest.param("127.0.0.1", id="loopback"),
|
|
||||||
pytest.param("10.0.0.1", id="private"),
|
|
||||||
pytest.param("169.254.169.254", id="link-local-metadata"),
|
|
||||||
pytest.param("::ffff:127.0.0.1", id="ipv4-mapped-loopback"),
|
|
||||||
pytest.param("64:ff9b::7f00:1", id="nat64-wrapping-loopback"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=False)
|
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=False)
|
||||||
def test_blocks_private_loopback_linklocal(
|
def test_blocks_private_loopback_linklocal(
|
||||||
self,
|
self,
|
||||||
local_http_server: LocalHTTPServer,
|
httpx_mock: HTTPXMock,
|
||||||
fake_dns: FakeDNS,
|
resolve_to,
|
||||||
dial_recorder: DialRecorder,
|
|
||||||
address: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A webhook host resolving to a non-public address
|
- URL with a private, loopback, or link-local IP address
|
||||||
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is False
|
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is False
|
||||||
WHEN:
|
WHEN:
|
||||||
- send_webhook is called
|
- send_webhook is called with such URL
|
||||||
THEN:
|
THEN:
|
||||||
- The request is blocked before any connection is opened
|
- ValueError is raised
|
||||||
"""
|
"""
|
||||||
fake_dns.add("webhook.test", address)
|
resolve_to("127.0.0.1")
|
||||||
|
with pytest.raises(ConnectError):
|
||||||
with pytest.raises(OutboundRequestBlockedError):
|
|
||||||
send_webhook(
|
send_webhook(
|
||||||
f"http://webhook.test:{local_http_server.port}",
|
"http://paperless-ngx.com",
|
||||||
data="",
|
data="",
|
||||||
headers={},
|
headers={},
|
||||||
files=None,
|
files=None,
|
||||||
as_json=False,
|
as_json=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert local_http_server.connections == 0
|
def test_allows_public_ip_and_sends(
|
||||||
assert dial_recorder.hosts() == []
|
|
||||||
|
|
||||||
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=False)
|
|
||||||
@pytest.mark.usefixtures("every_address_is_public")
|
|
||||||
def test_sends_to_validated_address(
|
|
||||||
self,
|
self,
|
||||||
local_http_server: LocalHTTPServer,
|
httpx_mock: HTTPXMock,
|
||||||
fake_dns: FakeDNS,
|
resolve_to,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A webhook host resolving to an address the policy accepts
|
- URL with a public IP address
|
||||||
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is False
|
|
||||||
WHEN:
|
WHEN:
|
||||||
- send_webhook is called
|
- send_webhook is called with such URL
|
||||||
THEN:
|
THEN:
|
||||||
- The payload arrives with the webhook hostname in the Host header
|
- Request is sent successfully
|
||||||
"""
|
"""
|
||||||
fake_dns.add("webhook.test", "127.0.0.1")
|
resolve_to("52.207.186.75")
|
||||||
|
httpx_mock.add_response(content=b"ok")
|
||||||
|
|
||||||
send_webhook(
|
send_webhook(
|
||||||
url=f"http://webhook.test:{local_http_server.port}",
|
url="http://paperless-ngx.com",
|
||||||
data="hi",
|
data="hi",
|
||||||
headers={},
|
headers={},
|
||||||
files=None,
|
files=None,
|
||||||
as_json=False,
|
as_json=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
received = local_http_server.requests[0]
|
req = httpx_mock.get_request()
|
||||||
assert received.body == b"hi"
|
assert req.url.host == "52.207.186.75"
|
||||||
assert received.headers["host"] == f"webhook.test:{local_http_server.port}"
|
assert req.headers["host"] == "paperless-ngx.com"
|
||||||
|
|
||||||
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=True)
|
def test_follow_redirects_disabled(self, httpx_mock: HTTPXMock, resolve_to) -> None:
|
||||||
def test_allow_internal_sends_to_internal_address(
|
|
||||||
self,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A webhook to localhost
|
|
||||||
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is True
|
|
||||||
WHEN:
|
|
||||||
- send_webhook is called
|
|
||||||
THEN:
|
|
||||||
- The payload arrives at the internal address
|
|
||||||
- The guard does not resolve the host, leaving it to the stock
|
|
||||||
connection path
|
|
||||||
"""
|
|
||||||
send_webhook(
|
|
||||||
url=f"http://localhost:{local_http_server.port}",
|
|
||||||
data="hi",
|
|
||||||
headers={},
|
|
||||||
files=None,
|
|
||||||
as_json=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
received = local_http_server.requests[0]
|
|
||||||
assert received.body == b"hi"
|
|
||||||
assert fake_dns.lookups == []
|
|
||||||
|
|
||||||
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=False)
|
|
||||||
def test_block_is_an_expected_task_failure(
|
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
dial_recorder: DialRecorder,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A webhook host resolving to a loopback address
|
|
||||||
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is False
|
|
||||||
WHEN:
|
|
||||||
- The webhook task runs through Celery
|
|
||||||
THEN:
|
|
||||||
- The task fails with the original block error, not a wrapper,
|
|
||||||
so it matches the task's expected errors, and is not retried
|
|
||||||
"""
|
|
||||||
fake_dns.add("webhook.test", "127.0.0.1")
|
|
||||||
retry = mocker.spy(send_webhook, "retry")
|
|
||||||
|
|
||||||
result = send_webhook.apply(
|
|
||||||
kwargs={
|
|
||||||
"url": f"http://webhook.test:{local_http_server.port}",
|
|
||||||
"data": "",
|
|
||||||
"headers": {},
|
|
||||||
"files": None,
|
|
||||||
"as_json": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.failed()
|
|
||||||
assert isinstance(result.result, OutboundRequestBlockedError)
|
|
||||||
assert isinstance(result.result, send_webhook.throws)
|
|
||||||
retry.assert_not_called()
|
|
||||||
assert local_http_server.connections == 0
|
|
||||||
assert dial_recorder.hosts() == []
|
|
||||||
|
|
||||||
def test_follow_redirects_disabled(self, httpx_mock: HTTPXMock) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A URL that redirects
|
- A URL that redirects
|
||||||
@@ -5267,6 +5290,7 @@ class TestWebhookSecurity:
|
|||||||
THEN:
|
THEN:
|
||||||
- Request is made to the original URL and does not follow the redirect
|
- Request is made to the original URL and does not follow the redirect
|
||||||
"""
|
"""
|
||||||
|
resolve_to("52.207.186.75")
|
||||||
# Return a redirect and ensure we don't follow it (only one request recorded)
|
# Return a redirect and ensure we don't follow it (only one request recorded)
|
||||||
httpx_mock.add_response(
|
httpx_mock.add_response(
|
||||||
status_code=302,
|
status_code=302,
|
||||||
@@ -5288,6 +5312,7 @@ class TestWebhookSecurity:
|
|||||||
def test_strips_user_supplied_host_header(
|
def test_strips_user_supplied_host_header(
|
||||||
self,
|
self,
|
||||||
httpx_mock: HTTPXMock,
|
httpx_mock: HTTPXMock,
|
||||||
|
resolve_to: Callable[[str], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -5295,8 +5320,9 @@ class TestWebhookSecurity:
|
|||||||
WHEN:
|
WHEN:
|
||||||
- send_webhook is called with a malicious Host header
|
- send_webhook is called with a malicious Host header
|
||||||
THEN:
|
THEN:
|
||||||
- The Host header is stripped and set from the URL hostname
|
- The Host header is stripped and replaced with the resolved hostname
|
||||||
"""
|
"""
|
||||||
|
resolve_to("52.207.186.75")
|
||||||
httpx_mock.add_response(content=b"ok")
|
httpx_mock.add_response(content=b"ok")
|
||||||
|
|
||||||
send_webhook(
|
send_webhook(
|
||||||
|
|||||||
+56
-53
@@ -49,7 +49,6 @@ from django.db.models import Sum
|
|||||||
from django.db.models import When
|
from django.db.models import When
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from django.db.models.manager import Manager
|
|
||||||
from django.http import FileResponse
|
from django.http import FileResponse
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@@ -256,7 +255,6 @@ from paperless.views import StandardPagination
|
|||||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||||
from paperless_ai.ai_classifier import get_llm_output_language
|
from paperless_ai.ai_classifier import get_llm_output_language
|
||||||
from paperless_ai.chat import stream_chat_with_documents
|
from paperless_ai.chat import stream_chat_with_documents
|
||||||
from paperless_ai.exceptions import LLMBlockedError
|
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
from paperless_ai.exceptions import LLMProviderError
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
from paperless_ai.matching import extract_unmatched_names
|
from paperless_ai.matching import extract_unmatched_names
|
||||||
@@ -1194,6 +1192,7 @@ class DocumentViewSet(
|
|||||||
"version_label",
|
"version_label",
|
||||||
"root_document_id",
|
"root_document_id",
|
||||||
"version_index",
|
"version_index",
|
||||||
|
"page_count",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"tags",
|
"tags",
|
||||||
@@ -1273,13 +1272,16 @@ class DocumentViewSet(
|
|||||||
if (
|
if (
|
||||||
"version" not in request.query_params
|
"version" not in request.query_params
|
||||||
or not isinstance(response.data, dict)
|
or not isinstance(response.data, dict)
|
||||||
or "content" not in response.data
|
or not ({"content", "page_count"} & response.data.keys())
|
||||||
):
|
):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
root_doc = self.get_object()
|
root_doc = self.get_object()
|
||||||
content_doc = self._resolve_file_doc(root_doc, request)
|
content_doc = self._resolve_file_doc(root_doc, request)
|
||||||
|
if "content" in response.data:
|
||||||
response.data["content"] = content_doc.content or ""
|
response.data["content"] = content_doc.content or ""
|
||||||
|
if "page_count" in response.data:
|
||||||
|
response.data["page_count"] = content_doc.page_count
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
@@ -1698,23 +1700,6 @@ class DocumentViewSet(
|
|||||||
},
|
},
|
||||||
status=status.HTTP_502_BAD_GATEWAY,
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
)
|
)
|
||||||
except LLMBlockedError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"AI backend request for document %s was blocked: %s",
|
|
||||||
doc.pk,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"ai": [
|
|
||||||
_(
|
|
||||||
"AI backend request was blocked by the outbound "
|
|
||||||
"request policy. Check logs for details.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
status=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
)
|
|
||||||
set_llm_suggestions_cache(
|
set_llm_suggestions_cache(
|
||||||
doc.pk,
|
doc.pk,
|
||||||
llm_suggestions,
|
llm_suggestions,
|
||||||
@@ -2986,11 +2971,15 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
document_objs = Document.objects.select_related("owner").filter(
|
root_docs = {
|
||||||
pk__in=documents,
|
get_root_document(doc)
|
||||||
)
|
for doc in Document.objects.select_related(
|
||||||
|
"owner",
|
||||||
|
"root_document__owner",
|
||||||
|
).filter(pk__in=documents)
|
||||||
|
}
|
||||||
user_is_owner_of_all_documents = all(
|
user_is_owner_of_all_documents = all(
|
||||||
(doc.owner == user or doc.owner is None) for doc in document_objs
|
(doc.owner == user or doc.owner is None) for doc in root_docs
|
||||||
)
|
)
|
||||||
|
|
||||||
# check global and object permissions for all documents
|
# check global and object permissions for all documents
|
||||||
@@ -2998,9 +2987,13 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
user.has_perm(
|
user.has_perm(
|
||||||
"documents.change_document",
|
"documents.change_document",
|
||||||
)
|
)
|
||||||
and not document_objs.exclude(
|
and not Document.global_objects.filter(
|
||||||
|
pk__in=[doc.pk for doc in root_docs],
|
||||||
|
)
|
||||||
|
.exclude(
|
||||||
pk__in=permitted_document_ids(user, perm="change_document"),
|
pk__in=permitted_document_ids(user, perm="change_document"),
|
||||||
).exists()
|
)
|
||||||
|
.exists()
|
||||||
)
|
)
|
||||||
|
|
||||||
# check ownership for methods that change original document
|
# check ownership for methods that change original document
|
||||||
@@ -3159,6 +3152,38 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
|
|
||||||
serializer_class = BulkEditSerializer
|
serializer_class = BulkEditSerializer
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
|
||||||
|
"""
|
||||||
|
Returns each document's current value of field, for the audit log.
|
||||||
|
|
||||||
|
Tags and custom fields are one row per value, so they are gathered
|
||||||
|
into a sorted list of pks per document (empty when there are none).
|
||||||
|
Reading them through Document.values() instead would join those rows
|
||||||
|
and return one arbitrary value per document.
|
||||||
|
"""
|
||||||
|
if field == "tags":
|
||||||
|
rows = (
|
||||||
|
Document.tags.through.objects.filter(document_id__in=doc_ids)
|
||||||
|
.order_by("tag_id")
|
||||||
|
.values_list("document_id", "tag_id")
|
||||||
|
)
|
||||||
|
elif field == "custom_fields":
|
||||||
|
rows = (
|
||||||
|
CustomFieldInstance.objects.filter(document_id__in=doc_ids)
|
||||||
|
.order_by("pk")
|
||||||
|
.values_list("document_id", "pk")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return dict(
|
||||||
|
Document.objects.filter(pk__in=doc_ids).values_list("pk", field),
|
||||||
|
)
|
||||||
|
|
||||||
|
values: dict[int, list[int]] = {doc_id: [] for doc_id in doc_ids}
|
||||||
|
for doc_id, pk in rows:
|
||||||
|
values[doc_id].append(pk)
|
||||||
|
return values
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
request_method = request.data.get("method")
|
request_method = request.data.get("method")
|
||||||
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
|
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
|
||||||
@@ -3205,41 +3230,19 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
try:
|
try:
|
||||||
modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None)
|
modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None)
|
||||||
if settings.AUDIT_LOG_ENABLED and modified_field:
|
if settings.AUDIT_LOG_ENABLED and modified_field:
|
||||||
old_documents = {
|
old_values = self._snapshot_field(documents, modified_field)
|
||||||
obj["pk"]: obj
|
|
||||||
for obj in Document.objects.filter(pk__in=documents).values(
|
|
||||||
"pk",
|
|
||||||
"correspondent",
|
|
||||||
"document_type",
|
|
||||||
"storage_path",
|
|
||||||
"tags",
|
|
||||||
"custom_fields",
|
|
||||||
"deleted_at",
|
|
||||||
"checksum",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
result = method(documents, **parameters)
|
result = method(documents, **parameters)
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED and modified_field:
|
if settings.AUDIT_LOG_ENABLED and modified_field:
|
||||||
new_documents = Document.objects.filter(pk__in=documents)
|
new_values = self._snapshot_field(documents, modified_field)
|
||||||
for doc in new_documents:
|
for doc in Document.objects.filter(pk__in=documents):
|
||||||
old_value = old_documents[doc.pk][modified_field]
|
|
||||||
new_value = getattr(doc, modified_field)
|
|
||||||
|
|
||||||
if isinstance(new_value, Model):
|
|
||||||
# correspondent, document type, etc.
|
|
||||||
new_value = new_value.pk
|
|
||||||
elif isinstance(new_value, Manager):
|
|
||||||
# tags, custom fields
|
|
||||||
new_value = list(new_value.values_list("pk", flat=True))
|
|
||||||
|
|
||||||
LogEntry.objects.log_create(
|
LogEntry.objects.log_create(
|
||||||
instance=doc,
|
instance=doc,
|
||||||
changes={
|
changes={
|
||||||
modified_field: [
|
modified_field: [
|
||||||
old_value,
|
old_values[doc.pk],
|
||||||
new_value,
|
new_values[doc.pk],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
action=LogEntry.Action.UPDATE,
|
action=LogEntry.Action.UPDATE,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import httpx
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from paperless.network import GuardedHTTPTransport
|
from paperless.network import PinnedHostHTTPTransport
|
||||||
from paperless.network import OutboundRequestBlockedError
|
|
||||||
from paperless.network import validate_outbound_http_url
|
from paperless.network import validate_outbound_http_url
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.workflows.webhooks")
|
logger = logging.getLogger("paperless.workflows.webhooks")
|
||||||
@@ -15,7 +14,7 @@ logger = logging.getLogger("paperless.workflows.webhooks")
|
|||||||
retry_backoff=True,
|
retry_backoff=True,
|
||||||
autoretry_for=(httpx.HTTPStatusError,),
|
autoretry_for=(httpx.HTTPStatusError,),
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
throws=(httpx.HTTPError, OutboundRequestBlockedError),
|
throws=(httpx.HTTPError,),
|
||||||
)
|
)
|
||||||
def send_webhook(
|
def send_webhook(
|
||||||
url: str,
|
url: str,
|
||||||
@@ -30,15 +29,14 @@ def send_webhook(
|
|||||||
url,
|
url,
|
||||||
allowed_schemes=settings.WEBHOOKS_ALLOWED_SCHEMES,
|
allowed_schemes=settings.WEBHOOKS_ALLOWED_SCHEMES,
|
||||||
allowed_ports=settings.WEBHOOKS_ALLOWED_PORTS,
|
allowed_ports=settings.WEBHOOKS_ALLOWED_PORTS,
|
||||||
# Scheme and port only; the transport enforces the internal-address
|
# Internal-address checks happen in transport to preserve ConnectError behavior.
|
||||||
# policy at connect time, on the address actually dialled.
|
|
||||||
allow_internal=True,
|
allow_internal=True,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning("Webhook blocked: %s", e)
|
logger.warning("Webhook blocked: %s", e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
transport = GuardedHTTPTransport(
|
transport = PinnedHostHTTPTransport(
|
||||||
allow_internal=settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS,
|
allow_internal=settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-21 19:00+0000\n"
|
"POT-Creation-Date: 2026-09-28 06:26+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1632,7 +1632,7 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:514 documents/serialisers.py:871
|
#: documents/serialisers.py:514 documents/serialisers.py:871
|
||||||
#: documents/serialisers.py:2885 documents/views.py:343 documents/views.py:2726
|
#: documents/serialisers.py:2895 documents/views.py:342 documents/views.py:2729
|
||||||
#: paperless_mail/serialisers.py:156
|
#: paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1641,39 +1641,39 @@ msgstr ""
|
|||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2352
|
#: documents/serialisers.py:2362
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2396
|
#: documents/serialisers.py:2406
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2403
|
#: documents/serialisers.py:2413
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2420 documents/serialisers.py:2430
|
#: documents/serialisers.py:2430 documents/serialisers.py:2440
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2425
|
#: documents/serialisers.py:2435
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2572
|
#: documents/serialisers.py:2582
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2941
|
#: documents/serialisers.py:2951
|
||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2971 documents/views.py:4763
|
#: documents/serialisers.py:2981 documents/views.py:4784
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1941,40 +1941,40 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:336 documents/views.py:2723
|
#: documents/views.py:335 documents/views.py:2726
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1670
|
#: documents/views.py:1673
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1681
|
#: documents/views.py:1684
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1693
|
#: documents/views.py:1696
|
||||||
msgid "AI backend rejected the request. Check logs for details."
|
msgid "AI backend rejected the request. Check logs for details."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2548 documents/views.py:2864
|
#: documents/views.py:2551 documents/views.py:2867
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4776
|
#: documents/views.py:4797
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4822
|
#: documents/views.py:4843
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4886
|
#: documents/views.py:4907
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4900
|
#: documents/views.py:4921
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2219,194 +2219,198 @@ msgid "Sets the LLM embedding model"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:369
|
#: paperless/models.py:369
|
||||||
msgid "Sets the LLM embedding endpoint, optional"
|
msgid "Sets the LLM embedding API key"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:376
|
#: paperless/models.py:376
|
||||||
|
msgid "Sets the LLM embedding endpoint, optional"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: paperless/models.py:383
|
||||||
msgid "Sets the LLM embedding chunk size"
|
msgid "Sets the LLM embedding chunk size"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:382
|
#: paperless/models.py:389
|
||||||
msgid "Sets the LLM context size"
|
msgid "Sets the LLM context size"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:388
|
#: paperless/models.py:395
|
||||||
msgid "Sets the LLM backend"
|
msgid "Sets the LLM backend"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:396
|
#: paperless/models.py:403
|
||||||
msgid "Sets the LLM model"
|
msgid "Sets the LLM model"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:403
|
#: paperless/models.py:410
|
||||||
msgid "Sets the LLM API key"
|
msgid "Sets the LLM API key"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:410
|
#: paperless/models.py:417
|
||||||
msgid "Sets the LLM endpoint, optional"
|
msgid "Sets the LLM endpoint, optional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:417
|
#: paperless/models.py:424
|
||||||
msgid "Sets the LLM output language"
|
msgid "Sets the LLM output language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:424
|
#: paperless/models.py:431
|
||||||
msgid "Sets the LLM timeout in seconds"
|
msgid "Sets the LLM timeout in seconds"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:430
|
#: paperless/models.py:437
|
||||||
msgid "paperless application settings"
|
msgid "paperless application settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:558
|
#: paperless/settings/__init__.py:559
|
||||||
msgid "English (US)"
|
msgid "English (US)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:559
|
#: paperless/settings/__init__.py:560
|
||||||
msgid "Arabic"
|
msgid "Arabic"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:560
|
#: paperless/settings/__init__.py:561
|
||||||
msgid "Afrikaans"
|
msgid "Afrikaans"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:561
|
#: paperless/settings/__init__.py:562
|
||||||
msgid "Belarusian"
|
msgid "Belarusian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:562
|
#: paperless/settings/__init__.py:563
|
||||||
msgid "Bulgarian"
|
msgid "Bulgarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:563
|
#: paperless/settings/__init__.py:564
|
||||||
msgid "Catalan"
|
msgid "Catalan"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:564
|
#: paperless/settings/__init__.py:565
|
||||||
msgid "Czech"
|
msgid "Czech"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:565
|
#: paperless/settings/__init__.py:566
|
||||||
msgid "Danish"
|
msgid "Danish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:566
|
#: paperless/settings/__init__.py:567
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:567
|
#: paperless/settings/__init__.py:568
|
||||||
msgid "Greek"
|
msgid "Greek"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:568
|
#: paperless/settings/__init__.py:569
|
||||||
msgid "English (GB)"
|
msgid "English (GB)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:569
|
#: paperless/settings/__init__.py:570
|
||||||
msgid "Spanish"
|
msgid "Spanish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:570
|
#: paperless/settings/__init__.py:571
|
||||||
msgid "Persian"
|
msgid "Persian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:571
|
#: paperless/settings/__init__.py:572
|
||||||
msgid "Finnish"
|
msgid "Finnish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:572
|
#: paperless/settings/__init__.py:573
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:573
|
#: paperless/settings/__init__.py:574
|
||||||
msgid "Hungarian"
|
msgid "Hungarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:574
|
#: paperless/settings/__init__.py:575
|
||||||
msgid "Indonesian"
|
msgid "Indonesian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:575
|
#: paperless/settings/__init__.py:576
|
||||||
msgid "Italian"
|
msgid "Italian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:576
|
#: paperless/settings/__init__.py:577
|
||||||
msgid "Japanese"
|
msgid "Japanese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:577
|
#: paperless/settings/__init__.py:578
|
||||||
msgid "Korean"
|
msgid "Korean"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:578
|
#: paperless/settings/__init__.py:579
|
||||||
msgid "Luxembourgish"
|
msgid "Luxembourgish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:579
|
#: paperless/settings/__init__.py:580
|
||||||
msgid "Norwegian"
|
msgid "Norwegian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:580
|
#: paperless/settings/__init__.py:581
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:581
|
#: paperless/settings/__init__.py:582
|
||||||
msgid "Polish"
|
msgid "Polish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:582
|
#: paperless/settings/__init__.py:583
|
||||||
msgid "Portuguese (Brazil)"
|
msgid "Portuguese (Brazil)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:583
|
#: paperless/settings/__init__.py:584
|
||||||
msgid "Portuguese"
|
msgid "Portuguese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:584
|
#: paperless/settings/__init__.py:585
|
||||||
msgid "Romanian"
|
msgid "Romanian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:585
|
#: paperless/settings/__init__.py:586
|
||||||
msgid "Russian"
|
msgid "Russian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:586
|
#: paperless/settings/__init__.py:587
|
||||||
msgid "Slovak"
|
msgid "Slovak"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:587
|
#: paperless/settings/__init__.py:588
|
||||||
msgid "Slovenian"
|
msgid "Slovenian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:588
|
#: paperless/settings/__init__.py:589
|
||||||
msgid "Serbian"
|
msgid "Serbian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:589
|
#: paperless/settings/__init__.py:590
|
||||||
msgid "Swedish"
|
msgid "Swedish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:590
|
#: paperless/settings/__init__.py:591
|
||||||
msgid "Turkish"
|
msgid "Turkish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:591
|
#: paperless/settings/__init__.py:592
|
||||||
msgid "Ukrainian"
|
msgid "Ukrainian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:592
|
#: paperless/settings/__init__.py:593
|
||||||
msgid "Vietnamese"
|
msgid "Vietnamese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:593
|
#: paperless/settings/__init__.py:594
|
||||||
msgid "Chinese Simplified"
|
msgid "Chinese Simplified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:594
|
#: paperless/settings/__init__.py:595
|
||||||
msgid "Chinese Traditional"
|
msgid "Chinese Traditional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/urls.py:435
|
#: paperless/urls.py:438
|
||||||
msgid "Paperless-ngx administration"
|
msgid "Paperless-ngx administration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@@ -244,6 +245,7 @@ class AIConfig(BaseConfig):
|
|||||||
ai_enabled: bool = dataclasses.field(init=False)
|
ai_enabled: bool = dataclasses.field(init=False)
|
||||||
llm_embedding_backend: str = dataclasses.field(init=False)
|
llm_embedding_backend: str = dataclasses.field(init=False)
|
||||||
llm_embedding_model: str = dataclasses.field(init=False)
|
llm_embedding_model: str = dataclasses.field(init=False)
|
||||||
|
llm_embedding_api_key: str = dataclasses.field(init=False)
|
||||||
llm_embedding_endpoint: str = dataclasses.field(init=False)
|
llm_embedding_endpoint: str = dataclasses.field(init=False)
|
||||||
llm_embedding_chunk_size: int = dataclasses.field(init=False)
|
llm_embedding_chunk_size: int = dataclasses.field(init=False)
|
||||||
llm_context_size: int = dataclasses.field(init=False)
|
llm_context_size: int = dataclasses.field(init=False)
|
||||||
@@ -254,6 +256,7 @@ class AIConfig(BaseConfig):
|
|||||||
llm_endpoint: str = dataclasses.field(init=False)
|
llm_endpoint: str = dataclasses.field(init=False)
|
||||||
llm_output_language: str = dataclasses.field(init=False)
|
llm_output_language: str = dataclasses.field(init=False)
|
||||||
llm_allow_internal_endpoints: bool = dataclasses.field(init=False)
|
llm_allow_internal_endpoints: bool = dataclasses.field(init=False)
|
||||||
|
llm_extra_params: dict[str, Any] = dataclasses.field(init=False)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
app_config = self._get_config_instance()
|
app_config = self._get_config_instance()
|
||||||
@@ -269,6 +272,9 @@ class AIConfig(BaseConfig):
|
|||||||
self.llm_embedding_model = (
|
self.llm_embedding_model = (
|
||||||
app_config.llm_embedding_model or settings.LLM_EMBEDDING_MODEL
|
app_config.llm_embedding_model or settings.LLM_EMBEDDING_MODEL
|
||||||
)
|
)
|
||||||
|
self.llm_embedding_api_key = (
|
||||||
|
app_config.llm_embedding_api_key or settings.LLM_EMBEDDING_API_KEY
|
||||||
|
)
|
||||||
self.llm_embedding_endpoint = (
|
self.llm_embedding_endpoint = (
|
||||||
app_config.llm_embedding_endpoint or settings.LLM_EMBEDDING_ENDPOINT
|
app_config.llm_embedding_endpoint or settings.LLM_EMBEDDING_ENDPOINT
|
||||||
)
|
)
|
||||||
@@ -287,6 +293,7 @@ class AIConfig(BaseConfig):
|
|||||||
app_config.llm_output_language or settings.LLM_OUTPUT_LANGUAGE
|
app_config.llm_output_language or settings.LLM_OUTPUT_LANGUAGE
|
||||||
)
|
)
|
||||||
self.llm_allow_internal_endpoints = settings.LLM_ALLOW_INTERNAL_ENDPOINTS
|
self.llm_allow_internal_endpoints = settings.LLM_ALLOW_INTERNAL_ENDPOINTS
|
||||||
|
self.llm_extra_params = settings.LLM_EXTRA_PARAMS
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def llm_index_enabled(self) -> bool:
|
def llm_index_enabled(self) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-09-11 09:32
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("paperless", "0016_alter_applicationconfiguration_ai_enabled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="applicationconfiguration",
|
||||||
|
name="llm_embedding_api_key",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
max_length=1024,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Sets the LLM embedding API key",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -365,6 +365,13 @@ class ApplicationConfiguration(AbstractSingletonModel):
|
|||||||
max_length=128,
|
max_length=128,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
llm_embedding_api_key = models.CharField(
|
||||||
|
verbose_name=_("Sets the LLM embedding API key"),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
max_length=1024,
|
||||||
|
)
|
||||||
|
|
||||||
llm_embedding_endpoint = models.CharField(
|
llm_embedding_endpoint = models.CharField(
|
||||||
verbose_name=_("Sets the LLM embedding endpoint, optional"),
|
verbose_name=_("Sets the LLM embedding endpoint, optional"),
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|||||||
+158
-519
@@ -1,533 +1,61 @@
|
|||||||
import functools
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
import socket
|
import socket
|
||||||
import time
|
|
||||||
from collections.abc import Callable
|
|
||||||
from collections.abc import Collection
|
from collections.abc import Collection
|
||||||
from collections.abc import Iterable
|
|
||||||
from enum import StrEnum
|
|
||||||
from typing import Any
|
|
||||||
from typing import Final
|
|
||||||
from typing import Self
|
|
||||||
from typing import TypeAlias
|
|
||||||
from urllib.parse import ParseResult
|
from urllib.parse import ParseResult
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import anyio
|
|
||||||
import httpcore
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
# Not exported by httpcore; the guard asserts it is still the async default.
|
# Ranges ipaddress does not report as private, but which routinely front
|
||||||
from httpcore._backends.auto import AutoBackend
|
# internal infrastructure.
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.network")
|
|
||||||
|
|
||||||
# requires-python is >=3.11, so no PEP 695 `type` statement.
|
|
||||||
IPAddress: TypeAlias = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
||||||
|
|
||||||
# Ranges that ipaddress reports as global but which still reach internal hosts.
|
|
||||||
_NON_PUBLIC_NETWORKS = (
|
_NON_PUBLIC_NETWORKS = (
|
||||||
|
# RFC 6598 shared address space: ISP CGNAT, and the default pod/service
|
||||||
|
# CIDR on several managed Kubernetes offerings.
|
||||||
|
ipaddress.ip_network("100.64.0.0/10"),
|
||||||
# RFC 6052 NAT64 well-known prefix: 64:ff9b::7f00:1 is 127.0.0.1 wherever
|
# RFC 6052 NAT64 well-known prefix: 64:ff9b::7f00:1 is 127.0.0.1 wherever
|
||||||
# a NAT64 gateway exists, yet ipaddress classifies the prefix as global.
|
# a NAT64 gateway exists.
|
||||||
ipaddress.ip_network("64:ff9b::/96"),
|
ipaddress.ip_network("64:ff9b::/96"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BlockReason(StrEnum):
|
def is_public_ip(ip: str | int) -> bool:
|
||||||
NON_PUBLIC_ADDRESS = "non_public_address"
|
try:
|
||||||
UNIX_SOCKET = "unix_socket"
|
obj = ipaddress.ip_address(ip)
|
||||||
|
return not (
|
||||||
|
obj.is_private
|
||||||
class OutboundRequestBlockedError(Exception):
|
or obj.is_loopback
|
||||||
"""
|
or obj.is_link_local
|
||||||
An outbound connection was refused by policy before any socket was opened.
|
or obj.is_multicast
|
||||||
|
or obj.is_unspecified
|
||||||
For NON_PUBLIC_ADDRESS, ``host`` is the name or literal being connected to
|
or any(obj in network for network in _NON_PUBLIC_NETWORKS)
|
||||||
and ``address`` the first offending address. For UNIX_SOCKET, ``host`` is
|
|
||||||
the socket path and ``port`` and ``address`` are None.
|
|
||||||
|
|
||||||
``address`` is deliberately left out of the message: the message is logged
|
|
||||||
and stored on failed tasks, and must not disclose internal addresses.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
host: str,
|
|
||||||
port: int | None,
|
|
||||||
reason: BlockReason,
|
|
||||||
address: IPAddress | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.reason = reason
|
|
||||||
self.address = address
|
|
||||||
target = host if port is None else f"{host}:{port}"
|
|
||||||
super().__init__(f"Outbound connection to {target} blocked ({reason})")
|
|
||||||
|
|
||||||
def __reduce__(self) -> tuple[Callable[..., Self], tuple[object, ...]]:
|
|
||||||
# Celery rebuilds failed-task exceptions by pickling; keyword-only
|
|
||||||
# fields cannot be recovered from ``args`` alone.
|
|
||||||
return (
|
|
||||||
functools.partial(
|
|
||||||
type(self),
|
|
||||||
host=self.host,
|
|
||||||
port=self.port,
|
|
||||||
reason=self.reason,
|
|
||||||
address=self.address,
|
|
||||||
),
|
|
||||||
(),
|
|
||||||
)
|
)
|
||||||
|
except ValueError: # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class HostResolutionError(Exception):
|
def resolve_hostname_ips(hostname: str) -> list[str]:
|
||||||
"""The resolver returned no usable addresses for a host."""
|
try:
|
||||||
|
addr_info = socket.getaddrinfo(hostname, None)
|
||||||
|
except socket.gaierror as e:
|
||||||
|
raise ValueError(f"Could not resolve hostname: {hostname}") from e
|
||||||
|
|
||||||
def __init__(self, *, host: str, detail: str) -> None:
|
ips = [info[4][0] for info in addr_info if info and info[4]]
|
||||||
self.host = host
|
if not ips:
|
||||||
self.detail = detail
|
raise ValueError(f"Could not resolve hostname: {hostname}")
|
||||||
super().__init__(f"Could not resolve {host}: {detail}")
|
return ips
|
||||||
|
|
||||||
def __reduce__(self) -> tuple[Callable[..., Self], tuple[object, ...]]:
|
|
||||||
return (
|
|
||||||
functools.partial(type(self), host=self.host, detail=self.detail),
|
|
||||||
(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def blocked_message(exc: OutboundRequestBlockedError | HostResolutionError) -> str:
|
def format_host_for_url(host: str) -> str:
|
||||||
"""User-facing text for validation errors, kept stable for existing callers."""
|
|
||||||
if isinstance(exc, HostResolutionError):
|
|
||||||
return f"Could not resolve hostname: {exc.host}"
|
|
||||||
if exc.reason is BlockReason.UNIX_SOCKET:
|
|
||||||
return "Connection blocked: unix sockets are not permitted"
|
|
||||||
return f"Connection blocked: {exc.host} resolves to a non-public address"
|
|
||||||
|
|
||||||
|
|
||||||
def is_public_ip(ip: IPAddress) -> bool:
|
|
||||||
"""
|
"""
|
||||||
True when ``ip`` is globally routable unicast and not in a range that
|
Format IP address for URL use (wrap IPv6 in brackets).
|
||||||
ipaddress reports as global but which still reaches internal hosts.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
ip.is_global
|
|
||||||
and not ip.is_multicast
|
|
||||||
and not any(ip in network for network in _NON_PUBLIC_NETWORKS)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Resolver and clock indirection so tests can fake DNS and time for this module
|
|
||||||
# without changing how the stock httpcore backends resolve the literals the
|
|
||||||
# guard dials.
|
|
||||||
_getaddrinfo = socket.getaddrinfo
|
|
||||||
_agetaddrinfo = anyio.getaddrinfo
|
|
||||||
# The clock is a seam because time-machine does not mock monotonic clocks, and
|
|
||||||
# patching time.monotonic globally would also replace the asyncio event loop's
|
|
||||||
# own clock, hanging or misfiring its timers for the rest of the test.
|
|
||||||
_monotonic = time.monotonic
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_addresses(
|
|
||||||
host: str,
|
|
||||||
infos: Iterable[tuple[Any, ...]],
|
|
||||||
) -> tuple[IPAddress, ...]:
|
|
||||||
# Resolver output is always an address, but a scoped IPv6 answer carries a
|
|
||||||
# zone id ("fe80::1%1"), which is dropped before classification.
|
|
||||||
# dict keys keep the first occurrence and resolver order
|
|
||||||
addresses: dict[IPAddress, None] = {}
|
|
||||||
for info in infos:
|
|
||||||
address = ipaddress.ip_address(str(info[4][0]).split("%", 1)[0])
|
|
||||||
addresses.setdefault(address, None)
|
|
||||||
if not addresses:
|
|
||||||
raise HostResolutionError(host=host, detail="no addresses returned")
|
|
||||||
return tuple(addresses)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_public(
|
|
||||||
host: str,
|
|
||||||
port: int | None,
|
|
||||||
addresses: tuple[IPAddress, ...],
|
|
||||||
) -> tuple[IPAddress, ...]:
|
|
||||||
for address in addresses:
|
|
||||||
if not is_public_ip(address):
|
|
||||||
raise OutboundRequestBlockedError(
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
reason=BlockReason.NON_PUBLIC_ADDRESS,
|
|
||||||
address=address,
|
|
||||||
)
|
|
||||||
return addresses
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_public_addresses(host: str, port: int | None) -> tuple[IPAddress, ...]:
|
|
||||||
"""
|
|
||||||
Resolve ``host`` and return its addresses in resolver order, or raise if
|
|
||||||
any of them is non-public. A name is rejected as a whole; offending
|
|
||||||
addresses are never filtered out.
|
|
||||||
|
|
||||||
IP literals go through the resolver too: getaddrinfo answers them without
|
|
||||||
a lookup, and validating only its answer means no second parser can read
|
|
||||||
the host differently from the one that connects.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
infos = _getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
ip_obj = ipaddress.ip_address(host)
|
||||||
except (OSError, UnicodeError) as e:
|
if ip_obj.version == 6:
|
||||||
raise HostResolutionError(host=host, detail=str(e)) from e
|
return f"[{host}]"
|
||||||
return _require_public(host, port, _collect_addresses(host, infos))
|
return host
|
||||||
|
except ValueError:
|
||||||
|
return host
|
||||||
async def aresolve_public_addresses(
|
|
||||||
host: str,
|
|
||||||
port: int | None,
|
|
||||||
) -> tuple[IPAddress, ...]:
|
|
||||||
"""Async variant of resolve_public_addresses."""
|
|
||||||
try:
|
|
||||||
infos = await _agetaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
||||||
except (OSError, UnicodeError) as e:
|
|
||||||
raise HostResolutionError(host=host, detail=str(e)) from e
|
|
||||||
return _require_public(host, port, _collect_addresses(host, infos))
|
|
||||||
|
|
||||||
|
|
||||||
MAX_ADDRESSES_TRIED: Final = 8
|
|
||||||
MIN_ATTEMPT_TIMEOUT: Final = 2.0
|
|
||||||
MAX_ATTEMPT_TIMEOUT: Final = 10.0
|
|
||||||
|
|
||||||
|
|
||||||
def _require_positive_timeout(host: str, timeout: float | None) -> None:
|
|
||||||
# A zero timeout makes the socket non-blocking and a negative one is
|
|
||||||
# rejected by settimeout; neither can produce a useful connection attempt.
|
|
||||||
if timeout is not None and timeout <= 0:
|
|
||||||
raise httpcore.ConnectTimeout(
|
|
||||||
f"Connect timeout for {host} must be positive, got {timeout}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _deadline(timeout: float | None) -> float:
|
|
||||||
return math.inf if timeout is None else _monotonic() + timeout
|
|
||||||
|
|
||||||
|
|
||||||
def _attempt_order(addresses: tuple[IPAddress, ...]) -> list[IPAddress]:
|
|
||||||
# Alternate address families, starting with the resolver's first family
|
|
||||||
# (RFC 8305 section 4), so one unreachable family cannot delay the other.
|
|
||||||
first_version = addresses[0].version
|
|
||||||
primary = [a for a in addresses if a.version == first_version]
|
|
||||||
secondary = [a for a in addresses if a.version != first_version]
|
|
||||||
ordered: list[IPAddress] = []
|
|
||||||
for index in range(max(len(primary), len(secondary))):
|
|
||||||
ordered.extend(primary[index : index + 1])
|
|
||||||
ordered.extend(secondary[index : index + 1])
|
|
||||||
return ordered[:MAX_ADDRESSES_TRIED]
|
|
||||||
|
|
||||||
|
|
||||||
def _attempt_timeout(remaining: float, attempts_left: int) -> float:
|
|
||||||
"""
|
|
||||||
Budget for the next attempt. Once the budget is too small to split, or on
|
|
||||||
the last address, the attempt gets everything left. Otherwise it gets an
|
|
||||||
equal share clamped to [MIN, MAX], always leaving MIN for a later attempt.
|
|
||||||
The floor survives one lost SYN; the ceiling bounds how long a black-holed
|
|
||||||
address delays the next one.
|
|
||||||
"""
|
|
||||||
if attempts_left == 1 or remaining < 2 * MIN_ATTEMPT_TIMEOUT:
|
|
||||||
return remaining
|
|
||||||
share = remaining / attempts_left
|
|
||||||
return min(
|
|
||||||
MAX_ATTEMPT_TIMEOUT,
|
|
||||||
max(MIN_ATTEMPT_TIMEOUT, share),
|
|
||||||
remaining - MIN_ATTEMPT_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _as_httpcore_timeout(seconds: float) -> float | None:
|
|
||||||
return None if math.isinf(seconds) else seconds
|
|
||||||
|
|
||||||
|
|
||||||
def _log_block(error: OutboundRequestBlockedError) -> None:
|
|
||||||
logger.warning("Blocked outbound connection: %s", error)
|
|
||||||
|
|
||||||
|
|
||||||
def _budget_exhausted(host: str, tried: int, total: int) -> httpcore.ConnectTimeout:
|
|
||||||
return httpcore.ConnectTimeout(
|
|
||||||
f"Timed out connecting to {host} after trying {tried} of {total} addresses",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _next_attempt_budget(
|
|
||||||
host: str,
|
|
||||||
deadline: float,
|
|
||||||
candidates: list[IPAddress],
|
|
||||||
index: int,
|
|
||||||
) -> float:
|
|
||||||
"""Budget for the attempt at index, or a timeout if none is left."""
|
|
||||||
remaining = deadline - _monotonic()
|
|
||||||
if remaining <= 0:
|
|
||||||
raise _budget_exhausted(host, index, len(candidates))
|
|
||||||
return _attempt_timeout(remaining, len(candidates) - index)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_for_connect(host: str, port: int) -> tuple[IPAddress, ...]:
|
|
||||||
try:
|
|
||||||
return resolve_public_addresses(host, port)
|
|
||||||
except OutboundRequestBlockedError as e:
|
|
||||||
_log_block(e)
|
|
||||||
raise
|
|
||||||
except HostResolutionError as e:
|
|
||||||
raise httpcore.ConnectError(str(e)) from e
|
|
||||||
|
|
||||||
|
|
||||||
async def _aresolve_for_connect(
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
timeout: float | None,
|
|
||||||
) -> tuple[IPAddress, ...]:
|
|
||||||
# The scope closes before dialling; attempts are not nested inside it.
|
|
||||||
try:
|
|
||||||
with anyio.fail_after(timeout):
|
|
||||||
return await aresolve_public_addresses(host, port)
|
|
||||||
except TimeoutError as e:
|
|
||||||
raise httpcore.ConnectTimeout(f"Timed out resolving {host}") from e
|
|
||||||
except OutboundRequestBlockedError as e:
|
|
||||||
_log_block(e)
|
|
||||||
raise
|
|
||||||
except HostResolutionError as e:
|
|
||||||
raise httpcore.ConnectError(str(e)) from e
|
|
||||||
|
|
||||||
|
|
||||||
class _GuardedSyncBackend(httpcore.NetworkBackend):
|
|
||||||
"""
|
|
||||||
Wraps httpcore's sync backend. With internal addresses disallowed, it
|
|
||||||
resolves the origin host itself, rejects the name if any address is
|
|
||||||
non-public, and dials the validated literals so the checked address is
|
|
||||||
the connected one. TLS still verifies against the origin hostname.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, inner: httpcore.NetworkBackend, *, allow_internal: bool) -> None:
|
|
||||||
self._inner = inner
|
|
||||||
self._allow_internal = allow_internal
|
|
||||||
|
|
||||||
def connect_tcp(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
timeout: float | None = None,
|
|
||||||
local_address: str | None = None,
|
|
||||||
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
|
||||||
) -> httpcore.NetworkStream:
|
|
||||||
if self._allow_internal:
|
|
||||||
return self._inner.connect_tcp(
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
timeout=timeout,
|
|
||||||
local_address=local_address,
|
|
||||||
socket_options=socket_options,
|
|
||||||
)
|
|
||||||
_require_positive_timeout(host, timeout)
|
|
||||||
# Resolution is not charged to the budget, matching the stock backend.
|
|
||||||
candidates = _attempt_order(_resolve_for_connect(host, port))
|
|
||||||
deadline = _deadline(timeout)
|
|
||||||
last_error: httpcore.ConnectError | httpcore.ConnectTimeout | None = None
|
|
||||||
for index, address in enumerate(candidates):
|
|
||||||
budget = _next_attempt_budget(host, deadline, candidates, index)
|
|
||||||
try:
|
|
||||||
return self._inner.connect_tcp(
|
|
||||||
str(address),
|
|
||||||
port,
|
|
||||||
timeout=_as_httpcore_timeout(budget),
|
|
||||||
local_address=local_address,
|
|
||||||
socket_options=socket_options,
|
|
||||||
)
|
|
||||||
except (httpcore.ConnectError, httpcore.ConnectTimeout) as e:
|
|
||||||
logger.debug("Connecting to %s via %s failed: %s", host, address, e)
|
|
||||||
last_error = e
|
|
||||||
# candidates is never empty, so every address was tried and failed
|
|
||||||
raise last_error or _budget_exhausted(host, len(candidates), len(candidates))
|
|
||||||
|
|
||||||
def connect_unix_socket(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
timeout: float | None = None,
|
|
||||||
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
|
||||||
) -> httpcore.NetworkStream:
|
|
||||||
error = OutboundRequestBlockedError(
|
|
||||||
host=path,
|
|
||||||
port=None,
|
|
||||||
reason=BlockReason.UNIX_SOCKET,
|
|
||||||
)
|
|
||||||
_log_block(error)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
def sleep(self, seconds: float) -> None:
|
|
||||||
self._inner.sleep(seconds)
|
|
||||||
|
|
||||||
|
|
||||||
class _GuardedAsyncBackend(httpcore.AsyncNetworkBackend):
|
|
||||||
"""Async twin of _GuardedSyncBackend."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
inner: httpcore.AsyncNetworkBackend,
|
|
||||||
*,
|
|
||||||
allow_internal: bool,
|
|
||||||
) -> None:
|
|
||||||
self._inner = inner
|
|
||||||
self._allow_internal = allow_internal
|
|
||||||
|
|
||||||
async def connect_tcp(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
timeout: float | None = None,
|
|
||||||
local_address: str | None = None,
|
|
||||||
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
|
||||||
) -> httpcore.AsyncNetworkStream:
|
|
||||||
if self._allow_internal:
|
|
||||||
return await self._inner.connect_tcp(
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
timeout=timeout,
|
|
||||||
local_address=local_address,
|
|
||||||
socket_options=socket_options,
|
|
||||||
)
|
|
||||||
_require_positive_timeout(host, timeout)
|
|
||||||
# Resolution counts against the budget, matching the stock backend.
|
|
||||||
deadline = _deadline(timeout)
|
|
||||||
candidates = _attempt_order(await _aresolve_for_connect(host, port, timeout))
|
|
||||||
last_error: httpcore.ConnectError | httpcore.ConnectTimeout | None = None
|
|
||||||
for index, address in enumerate(candidates):
|
|
||||||
budget = _next_attempt_budget(host, deadline, candidates, index)
|
|
||||||
try:
|
|
||||||
return await self._inner.connect_tcp(
|
|
||||||
str(address),
|
|
||||||
port,
|
|
||||||
timeout=_as_httpcore_timeout(budget),
|
|
||||||
local_address=local_address,
|
|
||||||
socket_options=socket_options,
|
|
||||||
)
|
|
||||||
except (httpcore.ConnectError, httpcore.ConnectTimeout) as e:
|
|
||||||
logger.debug("Connecting to %s via %s failed: %s", host, address, e)
|
|
||||||
last_error = e
|
|
||||||
raise last_error or _budget_exhausted(host, len(candidates), len(candidates))
|
|
||||||
|
|
||||||
async def connect_unix_socket(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
timeout: float | None = None,
|
|
||||||
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
|
||||||
) -> httpcore.AsyncNetworkStream:
|
|
||||||
error = OutboundRequestBlockedError(
|
|
||||||
host=path,
|
|
||||||
port=None,
|
|
||||||
reason=BlockReason.UNIX_SOCKET,
|
|
||||||
)
|
|
||||||
_log_block(error)
|
|
||||||
raise error
|
|
||||||
|
|
||||||
async def sleep(self, seconds: float) -> None:
|
|
||||||
await self._inner.sleep(seconds)
|
|
||||||
|
|
||||||
|
|
||||||
_LAYOUT_ERROR = (
|
|
||||||
"Unexpected httpx transport layout; refusing to create a transport "
|
|
||||||
"without the outbound connection guard"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GuardedHTTPTransport(httpx.HTTPTransport):
|
|
||||||
"""
|
|
||||||
httpx transport whose connections pass through the outbound guard.
|
|
||||||
|
|
||||||
Deliberately accepts no proxy, uds or retries options: a proxy would be
|
|
||||||
dialled instead of the destination, and a unix socket bypasses TCP
|
|
||||||
entirely. Adding an option here is a reviewed change, not a pass-through.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *, allow_internal: bool) -> None:
|
|
||||||
super().__init__()
|
|
||||||
# httpx has no public hook for the network backend. Check the exact
|
|
||||||
# layout before swapping so an httpx or httpcore change fails loudly.
|
|
||||||
pool = self._pool
|
|
||||||
if (
|
|
||||||
type(pool) is not httpcore.ConnectionPool
|
|
||||||
or type(pool._network_backend) is not httpcore.SyncBackend
|
|
||||||
):
|
|
||||||
raise RuntimeError(_LAYOUT_ERROR)
|
|
||||||
pool._network_backend = _GuardedSyncBackend(
|
|
||||||
pool._network_backend,
|
|
||||||
allow_internal=allow_internal,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GuardedAsyncHTTPTransport(httpx.AsyncHTTPTransport):
|
|
||||||
"""Async twin of GuardedHTTPTransport."""
|
|
||||||
|
|
||||||
def __init__(self, *, allow_internal: bool) -> None:
|
|
||||||
super().__init__()
|
|
||||||
pool = self._pool
|
|
||||||
if (
|
|
||||||
type(pool) is not httpcore.AsyncConnectionPool
|
|
||||||
or type(pool._network_backend) is not AutoBackend
|
|
||||||
):
|
|
||||||
raise RuntimeError(_LAYOUT_ERROR)
|
|
||||||
pool._network_backend = _GuardedAsyncBackend(
|
|
||||||
pool._network_backend,
|
|
||||||
allow_internal=allow_internal,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_guarded_httpx_client(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
allow_internal: bool,
|
|
||||||
timeout: float,
|
|
||||||
) -> httpx.Client:
|
|
||||||
"""
|
|
||||||
Validate ``url`` up front, then build a client that re-checks at connect
|
|
||||||
time. The up-front check turns static misconfiguration into a ValueError
|
|
||||||
before any retry layer sees it.
|
|
||||||
"""
|
|
||||||
validate_outbound_http_url(url, allow_internal=allow_internal)
|
|
||||||
return httpx.Client(
|
|
||||||
transport=GuardedHTTPTransport(allow_internal=allow_internal),
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_guarded_async_httpx_client(
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
allow_internal: bool,
|
|
||||||
timeout: float,
|
|
||||||
) -> httpx.AsyncClient:
|
|
||||||
"""Async twin of create_guarded_httpx_client."""
|
|
||||||
validate_outbound_http_url(url, allow_internal=allow_internal)
|
|
||||||
return httpx.AsyncClient(
|
|
||||||
transport=GuardedAsyncHTTPTransport(allow_internal=allow_internal),
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# urllib3 treats a backslash as ending the authority while urlparse and httpx do
|
|
||||||
# not, so the host checked here could differ from the one that is dialled.
|
|
||||||
# Control and whitespace characters are refused for the same reason.
|
|
||||||
_UNSAFE_URL_CHARS = re.compile(r"[\\\x00-\x1f\x7f\s]")
|
|
||||||
|
|
||||||
|
|
||||||
def _dns_name(url: str) -> str:
|
|
||||||
"""
|
|
||||||
The ASCII hostname that httpx and urllib3 look up for ``url``.
|
|
||||||
|
|
||||||
urlparse keeps a non-ASCII hostname as typed, and getaddrinfo would then
|
|
||||||
encode it with the stdlib IDNA 2003 codec. That maps some characters
|
|
||||||
differently from the IDNA 2008 encoding the HTTP clients use ("faß"
|
|
||||||
becomes "fass" instead of "xn--fa-hia"), so the check would resolve a
|
|
||||||
different name from the one that is connected to.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return httpx.URL(url).raw_host.decode("ascii")
|
|
||||||
except (httpx.InvalidURL, UnicodeError) as e:
|
|
||||||
raise ValueError("Invalid URL scheme or hostname.") from e
|
|
||||||
|
|
||||||
|
|
||||||
def validate_outbound_http_url(
|
def validate_outbound_http_url(
|
||||||
@@ -553,17 +81,128 @@ def validate_outbound_http_url(
|
|||||||
raise ValueError("Destination port not permitted.")
|
raise ValueError("Destination port not permitted.")
|
||||||
|
|
||||||
if not allow_internal:
|
if not allow_internal:
|
||||||
if _UNSAFE_URL_CHARS.search(url):
|
for ip_str in resolve_hostname_ips(parsed.hostname):
|
||||||
raise ValueError("Invalid URL scheme or hostname.")
|
if not is_public_ip(ip_str):
|
||||||
host = _dns_name(url)
|
raise ValueError(
|
||||||
# HTTP clients may percent-decode the host before resolving it, so the
|
f"Connection blocked: {parsed.hostname} resolves to a non-public address",
|
||||||
# checked name could differ from the dialled one. An IPv6 zone id is the
|
)
|
||||||
# only legitimate use, and link-local addresses are non-public anyway.
|
|
||||||
if "%" in host:
|
|
||||||
raise ValueError("Invalid URL scheme or hostname.")
|
|
||||||
try:
|
|
||||||
resolve_public_addresses(host, port)
|
|
||||||
except (OutboundRequestBlockedError, HostResolutionError) as e:
|
|
||||||
raise ValueError(blocked_message(e)) from e
|
|
||||||
|
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_request_to_pinned_ip(
|
||||||
|
request: httpx.Request,
|
||||||
|
*,
|
||||||
|
allow_internal: bool,
|
||||||
|
) -> httpx.Request:
|
||||||
|
hostname = request.url.host
|
||||||
|
|
||||||
|
if not hostname:
|
||||||
|
raise httpx.ConnectError("No hostname in request URL")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ips = resolve_hostname_ips(hostname)
|
||||||
|
except ValueError as e:
|
||||||
|
raise httpx.ConnectError(str(e)) from e
|
||||||
|
|
||||||
|
if not allow_internal:
|
||||||
|
for ip_str in ips:
|
||||||
|
if not is_public_ip(ip_str):
|
||||||
|
raise httpx.ConnectError(
|
||||||
|
f"Connection blocked: {hostname} resolves to a non-public address",
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_str = ips[0]
|
||||||
|
formatted_ip = format_host_for_url(ip_str)
|
||||||
|
|
||||||
|
new_headers = httpx.Headers(request.headers)
|
||||||
|
if "host" in new_headers:
|
||||||
|
del new_headers["host"]
|
||||||
|
host_header = format_host_for_url(hostname)
|
||||||
|
default_port = 443 if request.url.scheme == "https" else 80
|
||||||
|
if request.url.port and request.url.port != default_port:
|
||||||
|
host_header = f"{host_header}:{request.url.port}"
|
||||||
|
new_headers["Host"] = host_header
|
||||||
|
new_url = request.url.copy_with(host=formatted_ip)
|
||||||
|
|
||||||
|
rewritten_request = httpx.Request(
|
||||||
|
method=request.method,
|
||||||
|
url=new_url,
|
||||||
|
headers=new_headers,
|
||||||
|
stream=request.stream,
|
||||||
|
extensions=request.extensions,
|
||||||
|
)
|
||||||
|
rewritten_request.extensions["sni_hostname"] = hostname
|
||||||
|
|
||||||
|
return rewritten_request
|
||||||
|
|
||||||
|
|
||||||
|
class PinnedHostHTTPTransport(httpx.HTTPTransport):
|
||||||
|
"""
|
||||||
|
HTTP transport that resolves/validates hostnames per request and connects to
|
||||||
|
a vetted IP while preserving the original Host header and TLS SNI hostname.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*args,
|
||||||
|
allow_internal: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.allow_internal = allow_internal
|
||||||
|
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
request = _rewrite_request_to_pinned_ip(
|
||||||
|
request,
|
||||||
|
allow_internal=self.allow_internal,
|
||||||
|
)
|
||||||
|
return super().handle_request(request)
|
||||||
|
|
||||||
|
|
||||||
|
class PinnedHostAsyncHTTPTransport(httpx.AsyncHTTPTransport):
|
||||||
|
"""
|
||||||
|
Async variant of PinnedHostHTTPTransport.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*args,
|
||||||
|
allow_internal: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.allow_internal = allow_internal
|
||||||
|
|
||||||
|
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
request = _rewrite_request_to_pinned_ip(
|
||||||
|
request,
|
||||||
|
allow_internal=self.allow_internal,
|
||||||
|
)
|
||||||
|
return await super().handle_async_request(request)
|
||||||
|
|
||||||
|
|
||||||
|
def create_pinned_httpx_client(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
allow_internal: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> httpx.Client:
|
||||||
|
validate_outbound_http_url(url, allow_internal=allow_internal)
|
||||||
|
return httpx.Client(
|
||||||
|
transport=PinnedHostHTTPTransport(allow_internal=allow_internal),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_pinned_async_httpx_client(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
allow_internal: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> httpx.AsyncClient:
|
||||||
|
validate_outbound_http_url(url, allow_internal=allow_internal)
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=PinnedHostAsyncHTTPTransport(allow_internal=allow_internal),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
from bleach import clean
|
|
||||||
from bleach import linkify
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import is_naive
|
from django.utils.timezone import is_naive
|
||||||
@@ -38,6 +36,9 @@ from humanize import naturalsize
|
|||||||
from imap_tools import MailAttachment
|
from imap_tools import MailAttachment
|
||||||
from imap_tools import MailMessage
|
from imap_tools import MailMessage
|
||||||
from tika_client import TikaClient
|
from tika_client import TikaClient
|
||||||
|
from turbohtml.clean import Linkify
|
||||||
|
from turbohtml.clean import linkify
|
||||||
|
from turbohtml.migration.bleach import clean
|
||||||
|
|
||||||
from documents.parsers import ParseError
|
from documents.parsers import ParseError
|
||||||
from documents.parsers import make_thumbnail_from_pdf
|
from documents.parsers import make_thumbnail_from_pdf
|
||||||
@@ -58,12 +59,6 @@ _SUPPORTED_MIME_TYPES: dict[str, str] = {
|
|||||||
"message/rfc822": ".eml",
|
"message/rfc822": ".eml",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bleach's email-address linkifier uses a superlinear regular expression. Keep
|
|
||||||
# email linkification for ordinary headers and short messages, but never run it
|
|
||||||
# over an unbounded attacker-controlled field. URL linkification remains enabled
|
|
||||||
# for longer text.
|
|
||||||
_MAX_EMAIL_LINKIFY_LENGTH = 2048
|
|
||||||
|
|
||||||
|
|
||||||
class MailDocumentParser:
|
class MailDocumentParser:
|
||||||
"""Parse .eml email files for Paperless-ngx.
|
"""Parse .eml email files for Paperless-ngx.
|
||||||
@@ -633,10 +628,7 @@ class MailDocumentParser:
|
|||||||
text = str(text)
|
text = str(text)
|
||||||
text = escape(text)
|
text = escape(text)
|
||||||
text = clean(text)
|
text = clean(text)
|
||||||
text = linkify(
|
text = linkify(text, Linkify(parse_email=True))
|
||||||
text,
|
|
||||||
parse_email="@" in text and len(text) <= _MAX_EMAIL_LINKIFY_LENGTH,
|
|
||||||
)
|
|
||||||
text = text.replace("\n", "<br>")
|
text = text.replace("\n", "<br>")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,11 @@ class ApplicationConfigurationSerializer(
|
|||||||
externally_configured_variables = serializers.SerializerMethodField()
|
externally_configured_variables = serializers.SerializerMethodField()
|
||||||
user_args = serializers.JSONField(binary=True, allow_null=True)
|
user_args = serializers.JSONField(binary=True, allow_null=True)
|
||||||
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
|
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
|
||||||
|
llm_embedding_api_key = ObfuscatedPasswordField(
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
max_length=1024,
|
||||||
|
)
|
||||||
llm_api_key = ObfuscatedPasswordField(
|
llm_api_key = ObfuscatedPasswordField(
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
@@ -227,7 +232,11 @@ class ApplicationConfigurationSerializer(
|
|||||||
max_length=1024,
|
max_length=1024,
|
||||||
)
|
)
|
||||||
|
|
||||||
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
|
OBFUSCATED_FIELDS = (
|
||||||
|
"llm_embedding_api_key",
|
||||||
|
"llm_api_key",
|
||||||
|
"remote_ocr_api_key",
|
||||||
|
)
|
||||||
|
|
||||||
def get_externally_configured_variables(
|
def get_externally_configured_variables(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import multiprocessing
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from typing import Final
|
from typing import Final
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -1081,6 +1082,25 @@ CLASSIFIER_LANGUAGES: Final[dict[str, str]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_llm_extra_params() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Parse PAPERLESS_AI_LLM_EXTRA_PARAMS, a JSON object passed straight through
|
||||||
|
to the LLM backend's request body.
|
||||||
|
"""
|
||||||
|
raw = os.getenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", "{}")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"PAPERLESS_AI_LLM_EXTRA_PARAMS must be valid JSON",
|
||||||
|
) from e
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"PAPERLESS_AI_LLM_EXTRA_PARAMS must be a JSON object",
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def _get_classifier_language_setting(ocr_lang: str) -> str | None:
|
def _get_classifier_language_setting(ocr_lang: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Maps the primary Tesseract language to the classifier's stemming
|
Maps the primary Tesseract language to the classifier's stemming
|
||||||
@@ -1216,6 +1236,7 @@ LLM_EMBEDDING_BACKEND = get_choice_from_env(
|
|||||||
{"huggingface", "openai-like", "ollama"},
|
{"huggingface", "openai-like", "ollama"},
|
||||||
)
|
)
|
||||||
LLM_EMBEDDING_MODEL = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_MODEL")
|
LLM_EMBEDDING_MODEL = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_MODEL")
|
||||||
|
LLM_EMBEDDING_API_KEY = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_API_KEY")
|
||||||
LLM_EMBEDDING_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT")
|
LLM_EMBEDDING_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT")
|
||||||
LLM_EMBEDDING_CHUNK_SIZE = get_int_from_env(
|
LLM_EMBEDDING_CHUNK_SIZE = get_int_from_env(
|
||||||
"PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE",
|
"PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE",
|
||||||
@@ -1241,3 +1262,4 @@ LLM_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env(
|
|||||||
"PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS",
|
"PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS",
|
||||||
"true",
|
"true",
|
||||||
)
|
)
|
||||||
|
LLM_EXTRA_PARAMS = _get_llm_extra_params()
|
||||||
|
|||||||
@@ -735,33 +735,24 @@ class TestParser:
|
|||||||
|
|
||||||
assert expected_html == actual_html
|
assert expected_html == actual_html
|
||||||
|
|
||||||
def test_mail_to_html_bounds_email_linkification(
|
def test_mail_to_html_linkifies_email_in_long_text(
|
||||||
self,
|
self,
|
||||||
mail_parser: MailDocumentParser,
|
mail_parser: MailDocumentParser,
|
||||||
) -> None:
|
) -> None:
|
||||||
mail = mock.Mock(
|
mail = mock.Mock(
|
||||||
subject="sender@example.com",
|
subject="",
|
||||||
from_values=None,
|
from_values=None,
|
||||||
to_values=[],
|
to_values=[],
|
||||||
cc_values=[],
|
cc_values=[],
|
||||||
bcc_values=[],
|
bcc_values=[],
|
||||||
attachments=[],
|
attachments=[],
|
||||||
date=timezone.now(),
|
date=timezone.now(),
|
||||||
text=("a." * 1500) + "@example.com",
|
text=("a." * 1500) + " sender@example.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
with mock.patch(
|
html_file = mail_parser.mail_to_html(mail)
|
||||||
"paperless.parsers.mail.linkify",
|
|
||||||
side_effect=lambda text, **kwargs: text,
|
|
||||||
) as mock_linkify:
|
|
||||||
mail_parser.mail_to_html(mail)
|
|
||||||
|
|
||||||
parse_email_by_text = {
|
assert 'href="mailto:sender@example.com"' in html_file.read_text()
|
||||||
call.args[0]: call.kwargs["parse_email"]
|
|
||||||
for call in mock_linkify.call_args_list
|
|
||||||
}
|
|
||||||
assert parse_email_by_text["sender@example.com"] is True
|
|
||||||
assert parse_email_by_text[mail.text] is False
|
|
||||||
|
|
||||||
def test_generate_pdf_from_mail(
|
def test_generate_pdf_from_mail(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ class TestRemoteUser(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.user = UserFactory(username="temp_admin", superuser=True)
|
self.user = UserFactory(username="temp_admin", superuser=True)
|
||||||
|
|
||||||
|
# _parse_remote_user_settings() mutates these shared lists in place,
|
||||||
|
# so undo that after the test instead of leaking remote-user auth
|
||||||
|
# into every test that runs afterward.
|
||||||
|
original_middleware = list(settings.MIDDLEWARE)
|
||||||
|
original_auth_backends = list(settings.AUTHENTICATION_BACKENDS)
|
||||||
|
original_auth_classes = list(
|
||||||
|
settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _restore_remote_user_settings() -> None:
|
||||||
|
settings.MIDDLEWARE[:] = original_middleware
|
||||||
|
settings.AUTHENTICATION_BACKENDS[:] = original_auth_backends
|
||||||
|
settings.REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"][:] = (
|
||||||
|
original_auth_classes
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addCleanup(_restore_remote_user_settings)
|
||||||
|
|
||||||
def test_remote_user(self) -> None:
|
def test_remote_user(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from django.core.exceptions import ImproperlyConfigured
|
|||||||
|
|
||||||
from paperless.settings import _get_allauth_trusted_proxy_count
|
from paperless.settings import _get_allauth_trusted_proxy_count
|
||||||
from paperless.settings import _get_classifier_language_setting
|
from paperless.settings import _get_classifier_language_setting
|
||||||
|
from paperless.settings import _get_llm_extra_params
|
||||||
from paperless.settings import _get_search_language_setting
|
from paperless.settings import _get_search_language_setting
|
||||||
from paperless.settings import _parse_paperless_url
|
from paperless.settings import _parse_paperless_url
|
||||||
from paperless.settings import default_threads_per_worker
|
from paperless.settings import default_threads_per_worker
|
||||||
@@ -166,3 +167,45 @@ class TestPaperlessURLSettings(TestCase):
|
|||||||
|
|
||||||
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
|
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
|
||||||
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
|
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLlmExtraParams:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("env_value", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(None, {}, id="unset"),
|
||||||
|
pytest.param(
|
||||||
|
'{"reasoning_effort": "none"}',
|
||||||
|
{"reasoning_effort": "none"},
|
||||||
|
id="json-object",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parses(
|
||||||
|
self,
|
||||||
|
monkeypatch,
|
||||||
|
env_value,
|
||||||
|
expected,
|
||||||
|
):
|
||||||
|
if env_value is None:
|
||||||
|
monkeypatch.delenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", raising=False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", env_value)
|
||||||
|
assert _get_llm_extra_params() == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("env_value", "match"),
|
||||||
|
[
|
||||||
|
pytest.param("reasoning_effort=none", "valid JSON", id="invalid-json"),
|
||||||
|
pytest.param('["none"]', "JSON object", id="not-an-object"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_raises(
|
||||||
|
self,
|
||||||
|
monkeypatch,
|
||||||
|
env_value,
|
||||||
|
match,
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", env_value)
|
||||||
|
with pytest.raises(ImproperlyConfigured, match=match):
|
||||||
|
_get_llm_extra_params()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from allauth.mfa import app_settings as mfa_settings
|
||||||
|
from allauth.mfa.totp.internal import auth as totp_auth
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminAuth(TestCase):
|
||||||
|
def test_admin_login_redirects_to_allauth(self):
|
||||||
|
user = UserFactory(staff=True, password="testpassword")
|
||||||
|
admin_url = reverse("admin:index")
|
||||||
|
login_url = reverse("admin:login")
|
||||||
|
expected_url = f"{reverse('account_login')}?next={admin_url}"
|
||||||
|
|
||||||
|
response = self.client.get(login_url, {"next": admin_url})
|
||||||
|
self.assertRedirects(response, expected_url)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
login_url,
|
||||||
|
{"username": user.username, "password": "testpassword", "next": admin_url},
|
||||||
|
)
|
||||||
|
self.assertRedirects(response, expected_url)
|
||||||
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
|
|
||||||
|
def test_admin_access_requires_totp_for_enrolled_staff(self):
|
||||||
|
user = UserFactory(staff=True, password="testpassword")
|
||||||
|
secret = totp_auth.generate_totp_secret()
|
||||||
|
totp_auth.TOTP.activate(user, secret)
|
||||||
|
admin_url = reverse("admin:index")
|
||||||
|
mfa_url = reverse("mfa_authenticate")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("account_login"),
|
||||||
|
{"login": user.username, "password": "testpassword", "next": admin_url},
|
||||||
|
)
|
||||||
|
self.assertRedirects(response, mfa_url)
|
||||||
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
|
self.assertRedirects(
|
||||||
|
self.client.get(admin_url),
|
||||||
|
f"{reverse('admin:login')}?next={admin_url}",
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(mfa_url, {"code": "invalid"})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
|
|
||||||
|
code = totp_auth.format_hotp_value(
|
||||||
|
totp_auth.hotp_value(secret, int(time.time()) // mfa_settings.TOTP_PERIOD),
|
||||||
|
)
|
||||||
|
response = self.client.post(mfa_url, {"code": code})
|
||||||
|
self.assertRedirects(response, admin_url)
|
||||||
|
self.assertEqual(self.client.session["_auth_user_id"], str(user.pk))
|
||||||
|
|
||||||
|
def test_staff_without_totp_can_still_log_in(self):
|
||||||
|
user = UserFactory(staff=True, password="testpassword")
|
||||||
|
admin_url = reverse("admin:index")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("account_login"),
|
||||||
|
{"login": user.username, "password": "testpassword", "next": admin_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, admin_url)
|
||||||
|
self.assertEqual(self.client.session["_auth_user_id"], str(user.pk))
|
||||||
@@ -30,3 +30,27 @@ class TestBooleanConfigPrecedence(TestCase):
|
|||||||
config.save()
|
config.save()
|
||||||
|
|
||||||
self.assertTrue(AIConfig().ai_enabled)
|
self.assertTrue(AIConfig().ai_enabled)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAIConfigPrecedence(TestCase):
|
||||||
|
@override_settings(LLM_EMBEDDING_API_KEY="environment-embedding-key")
|
||||||
|
def test_database_embedding_api_key_overrides_environment_setting(self) -> None:
|
||||||
|
config, _ = ApplicationConfiguration.objects.get_or_create()
|
||||||
|
config.llm_embedding_api_key = "database-embedding-key"
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
AIConfig().llm_embedding_api_key,
|
||||||
|
"database-embedding-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(LLM_EMBEDDING_API_KEY="environment-embedding-key")
|
||||||
|
def test_null_embedding_api_key_uses_environment_setting(self) -> None:
|
||||||
|
config, _ = ApplicationConfiguration.objects.get_or_create()
|
||||||
|
config.llm_embedding_api_key = None
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
AIConfig().llm_embedding_api_key,
|
||||||
|
"environment-embedding-key",
|
||||||
|
)
|
||||||
|
|||||||
+71
-1448
File diff suppressed because it is too large
Load Diff
@@ -1,375 +0,0 @@
|
|||||||
import ipaddress
|
|
||||||
import os
|
|
||||||
|
|
||||||
import httpcore
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
from pytest_mock import MockerFixture
|
|
||||||
|
|
||||||
from paperless.network import GuardedAsyncHTTPTransport
|
|
||||||
from paperless.network import GuardedHTTPTransport
|
|
||||||
from paperless.network import OutboundRequestBlockedError
|
|
||||||
from paperless.network import create_guarded_httpx_client
|
|
||||||
from paperless_testing.outbound import DialRecorder
|
|
||||||
from paperless_testing.outbound import FakeDNS
|
|
||||||
from paperless_testing.outbound import LocalHTTPServer
|
|
||||||
from paperless_testing.outbound import running_http_server
|
|
||||||
|
|
||||||
|
|
||||||
class TestGuardedTransportSync:
|
|
||||||
@pytest.mark.usefixtures("every_address_is_public")
|
|
||||||
def test_pinned_connection_falls_back_to_next_address(
|
|
||||||
self,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
dial_recorder: DialRecorder,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A hostname resolving to ::1 then 127.0.0.1
|
|
||||||
- A server listening on 127.0.0.1 only
|
|
||||||
- Internal addresses disallowed, with loopback treated as public
|
|
||||||
WHEN:
|
|
||||||
- A request is made
|
|
||||||
THEN:
|
|
||||||
- ::1 fails, 127.0.0.1 is dialled next and the request succeeds
|
|
||||||
"""
|
|
||||||
fake_dns.add("dual-stack.test", "::1", "127.0.0.1")
|
|
||||||
|
|
||||||
with httpx.Client(
|
|
||||||
transport=GuardedHTTPTransport(allow_internal=False),
|
|
||||||
timeout=5.0,
|
|
||||||
) as client:
|
|
||||||
response = client.get(f"http://dual-stack.test:{local_http_server.port}/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert dial_recorder.hosts() == ["::1", "127.0.0.1"]
|
|
||||||
|
|
||||||
def test_allow_internal_uses_stock_resolution(
|
|
||||||
self,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Internal addresses allowed
|
|
||||||
WHEN:
|
|
||||||
- A request is made to localhost
|
|
||||||
THEN:
|
|
||||||
- It succeeds without the guard resolving anything
|
|
||||||
"""
|
|
||||||
with httpx.Client(
|
|
||||||
transport=GuardedHTTPTransport(allow_internal=True),
|
|
||||||
timeout=5.0,
|
|
||||||
) as client:
|
|
||||||
response = client.get(f"http://localhost:{local_http_server.port}/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert fake_dns.lookups == []
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("every_address_is_public")
|
|
||||||
def test_host_header_is_the_hostname(
|
|
||||||
self,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A pinned connection to a named host
|
|
||||||
WHEN:
|
|
||||||
- A request is made
|
|
||||||
THEN:
|
|
||||||
- The server receives the hostname in Host, not the dialled IP
|
|
||||||
"""
|
|
||||||
fake_dns.add("pinned.test", "127.0.0.1")
|
|
||||||
|
|
||||||
with httpx.Client(
|
|
||||||
transport=GuardedHTTPTransport(allow_internal=False),
|
|
||||||
timeout=5.0,
|
|
||||||
) as client:
|
|
||||||
client.get(f"http://pinned.test:{local_http_server.port}/")
|
|
||||||
|
|
||||||
assert local_http_server.requests[0].headers["host"] == (
|
|
||||||
f"pinned.test:{local_http_server.port}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_redirect_to_internal_host_is_blocked(
|
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
dial_recorder: DialRecorder,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An allowed origin that redirects to a host resolving to a blocked
|
|
||||||
address, and a client that follows redirects
|
|
||||||
WHEN:
|
|
||||||
- The origin is requested
|
|
||||||
THEN:
|
|
||||||
- The redirect hop is blocked without dialling the blocked address
|
|
||||||
"""
|
|
||||||
allowed = ipaddress.ip_address("127.0.0.1")
|
|
||||||
mocker.patch(
|
|
||||||
"paperless.network.is_public_ip",
|
|
||||||
side_effect=lambda address: address == allowed,
|
|
||||||
)
|
|
||||||
fake_dns.add("origin.test", "127.0.0.1")
|
|
||||||
fake_dns.add("internal.test", "127.0.0.2")
|
|
||||||
local_http_server.redirect_to = (
|
|
||||||
f"http://internal.test:{local_http_server.port}/"
|
|
||||||
)
|
|
||||||
|
|
||||||
with (
|
|
||||||
httpx.Client(
|
|
||||||
transport=GuardedHTTPTransport(allow_internal=False),
|
|
||||||
timeout=5.0,
|
|
||||||
follow_redirects=True,
|
|
||||||
) as client,
|
|
||||||
pytest.raises(OutboundRequestBlockedError) as exc_info,
|
|
||||||
):
|
|
||||||
client.get(f"http://origin.test:{local_http_server.port}/")
|
|
||||||
|
|
||||||
assert exc_info.value.address == ipaddress.ip_address("127.0.0.2")
|
|
||||||
assert dial_recorder.hosts() == ["127.0.0.1"]
|
|
||||||
assert len(local_http_server.requests) == 1
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("every_address_is_public")
|
|
||||||
def test_connections_are_not_shared_between_hosts_on_one_address(
|
|
||||||
self,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
dial_recorder: DialRecorder,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Two hostnames resolving to the same address
|
|
||||||
- Internal addresses disallowed, with loopback treated as public
|
|
||||||
WHEN:
|
|
||||||
- One client requests the first host twice, then the second host
|
|
||||||
THEN:
|
|
||||||
- The first host's connection is reused for its second request
|
|
||||||
- The second host gets its own connection, so its certificate would
|
|
||||||
be checked rather than inheriting the first host's session
|
|
||||||
"""
|
|
||||||
fake_dns.add("first.test", "127.0.0.1")
|
|
||||||
fake_dns.add("second.test", "127.0.0.1")
|
|
||||||
|
|
||||||
with httpx.Client(
|
|
||||||
transport=GuardedHTTPTransport(allow_internal=False),
|
|
||||||
timeout=5.0,
|
|
||||||
) as client:
|
|
||||||
client.get(f"http://first.test:{local_http_server.port}/")
|
|
||||||
client.get(f"http://first.test:{local_http_server.port}/")
|
|
||||||
client.get(f"http://second.test:{local_http_server.port}/")
|
|
||||||
|
|
||||||
assert dial_recorder.hosts() == ["127.0.0.1", "127.0.0.1"]
|
|
||||||
assert local_http_server.connections == 2
|
|
||||||
assert [request.headers["host"] for request in local_http_server.requests] == [
|
|
||||||
f"first.test:{local_http_server.port}",
|
|
||||||
f"first.test:{local_http_server.port}",
|
|
||||||
f"second.test:{local_http_server.port}",
|
|
||||||
]
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("every_address_is_public")
|
|
||||||
def test_tls_uses_the_hostname_not_the_dialled_address(
|
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
dial_recorder: DialRecorder,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A pinned HTTPS connection to a named host
|
|
||||||
- A plain HTTP server, so the handshake itself fails
|
|
||||||
WHEN:
|
|
||||||
- A request is made
|
|
||||||
THEN:
|
|
||||||
- The validated address is dialled
|
|
||||||
- TLS is started with the hostname for SNI and certificate checks
|
|
||||||
"""
|
|
||||||
fake_dns.add("pinned.test", "127.0.0.1")
|
|
||||||
start_tls = mocker.spy(httpcore._backends.sync.SyncStream, "start_tls")
|
|
||||||
|
|
||||||
with (
|
|
||||||
httpx.Client(
|
|
||||||
transport=GuardedHTTPTransport(allow_internal=False),
|
|
||||||
timeout=5.0,
|
|
||||||
) as client,
|
|
||||||
pytest.raises(httpx.ConnectError),
|
|
||||||
):
|
|
||||||
client.get(f"https://pinned.test:{local_http_server.port}/")
|
|
||||||
|
|
||||||
assert dial_recorder.hosts() == ["127.0.0.1"]
|
|
||||||
start_tls.assert_called_once()
|
|
||||||
assert start_tls.call_args.kwargs["server_hostname"] == "pinned.test"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"host",
|
|
||||||
[
|
|
||||||
pytest.param("localhost", id="name"),
|
|
||||||
pytest.param("2130706433", id="decimal"),
|
|
||||||
pytest.param("0x7f.1", id="hex-short"),
|
|
||||||
pytest.param("127.1", id="short-dotted"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_blocks_internal_host_without_connecting(
|
|
||||||
self,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
dial_recorder: DialRecorder,
|
|
||||||
host: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Internal addresses disallowed
|
|
||||||
- A URL whose host reaches loopback, by name or by a
|
|
||||||
non-canonical spelling of 127.0.0.1
|
|
||||||
WHEN:
|
|
||||||
- A request is made through the transport
|
|
||||||
THEN:
|
|
||||||
- The resolved address is checked, the request is blocked and the
|
|
||||||
server never sees a connection
|
|
||||||
"""
|
|
||||||
with (
|
|
||||||
httpx.Client(
|
|
||||||
transport=GuardedHTTPTransport(allow_internal=False),
|
|
||||||
timeout=5.0,
|
|
||||||
) as client,
|
|
||||||
pytest.raises(OutboundRequestBlockedError),
|
|
||||||
):
|
|
||||||
client.get(f"http://{host}:{local_http_server.port}/")
|
|
||||||
|
|
||||||
assert local_http_server.connections == 0
|
|
||||||
assert dial_recorder.hosts() == []
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("every_address_is_public")
|
|
||||||
def test_environment_proxy_is_not_used(
|
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
dial_recorder: DialRecorder,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Proxy variables in the environment pointing at a second local server
|
|
||||||
- Internal addresses disallowed
|
|
||||||
WHEN:
|
|
||||||
- A request is made through the production client factory to an
|
|
||||||
allowed origin
|
|
||||||
THEN:
|
|
||||||
- The origin server receives the request directly and the proxy
|
|
||||||
server never sees a connection
|
|
||||||
"""
|
|
||||||
with running_http_server() as proxy_server:
|
|
||||||
mocker.patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"HTTP_PROXY": f"http://127.0.0.1:{proxy_server.port}",
|
|
||||||
"HTTPS_PROXY": f"http://127.0.0.1:{proxy_server.port}",
|
|
||||||
"ALL_PROXY": f"http://127.0.0.1:{proxy_server.port}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
fake_dns.add("origin.test", "127.0.0.1")
|
|
||||||
|
|
||||||
url = f"http://origin.test:{local_http_server.port}/"
|
|
||||||
with create_guarded_httpx_client(
|
|
||||||
url,
|
|
||||||
allow_internal=False,
|
|
||||||
timeout=5.0,
|
|
||||||
) as client:
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert len(local_http_server.requests) == 1
|
|
||||||
assert local_http_server.requests[0].headers["host"] == (
|
|
||||||
f"origin.test:{local_http_server.port}"
|
|
||||||
)
|
|
||||||
assert proxy_server.connections == 0
|
|
||||||
assert proxy_server.requests == []
|
|
||||||
assert dial_recorder.hosts() == ["127.0.0.1"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestGuardedTransportAsync:
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def anyio_backend(self) -> str:
|
|
||||||
return "asyncio"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
@pytest.mark.usefixtures("every_address_is_public")
|
|
||||||
async def test_pinned_connection_falls_back_to_next_address(
|
|
||||||
self,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
dial_recorder: DialRecorder,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A hostname resolving to ::1 then 127.0.0.1
|
|
||||||
- A server listening on 127.0.0.1 only
|
|
||||||
- Internal addresses disallowed, with loopback treated as public
|
|
||||||
WHEN:
|
|
||||||
- An async request is made
|
|
||||||
THEN:
|
|
||||||
- ::1 fails, 127.0.0.1 is dialled next and the request succeeds
|
|
||||||
"""
|
|
||||||
fake_dns.add("dual-stack.test", "::1", "127.0.0.1")
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=GuardedAsyncHTTPTransport(allow_internal=False),
|
|
||||||
timeout=5.0,
|
|
||||||
) as client:
|
|
||||||
response = await client.get(
|
|
||||||
f"http://dual-stack.test:{local_http_server.port}/",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert dial_recorder.hosts() == ["::1", "127.0.0.1"]
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_allow_internal_uses_stock_resolution(
|
|
||||||
self,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
fake_dns: FakeDNS,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Internal addresses allowed
|
|
||||||
WHEN:
|
|
||||||
- An async request is made to localhost
|
|
||||||
THEN:
|
|
||||||
- It succeeds without the guard resolving anything
|
|
||||||
"""
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=GuardedAsyncHTTPTransport(allow_internal=True),
|
|
||||||
timeout=5.0,
|
|
||||||
) as client:
|
|
||||||
response = await client.get(f"http://localhost:{local_http_server.port}/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert fake_dns.lookups == []
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_blocks_internal_host_without_connecting(
|
|
||||||
self,
|
|
||||||
local_http_server: LocalHTTPServer,
|
|
||||||
dial_recorder: DialRecorder,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Internal addresses disallowed
|
|
||||||
WHEN:
|
|
||||||
- An async request is made to localhost through the transport
|
|
||||||
THEN:
|
|
||||||
- It is blocked and the server never sees a connection
|
|
||||||
"""
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=GuardedAsyncHTTPTransport(allow_internal=False),
|
|
||||||
timeout=5.0,
|
|
||||||
) as client:
|
|
||||||
with pytest.raises(OutboundRequestBlockedError):
|
|
||||||
await client.get(f"http://localhost:{local_http_server.port}/")
|
|
||||||
|
|
||||||
assert local_http_server.connections == 0
|
|
||||||
assert dial_recorder.hosts() == []
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from allauth.account import views as allauth_account_views
|
from allauth.account import views as allauth_account_views
|
||||||
|
from allauth.account.decorators import secure_admin_login
|
||||||
from allauth.mfa.base import views as allauth_mfa_views
|
from allauth.mfa.base import views as allauth_mfa_views
|
||||||
from allauth.socialaccount import views as allauth_social_account_views
|
from allauth.socialaccount import views as allauth_social_account_views
|
||||||
from allauth.urls import build_provider_urlpatterns
|
from allauth.urls import build_provider_urlpatterns
|
||||||
@@ -68,6 +69,8 @@ from paperless_mail.views import MailRuleViewSet
|
|||||||
from paperless_mail.views import OauthCallbackView
|
from paperless_mail.views import OauthCallbackView
|
||||||
from paperless_mail.views import ProcessedMailViewSet
|
from paperless_mail.views import ProcessedMailViewSet
|
||||||
|
|
||||||
|
admin.site.login = secure_admin_login(admin.site.login)
|
||||||
|
|
||||||
api_router = DefaultRouter()
|
api_router = DefaultRouter()
|
||||||
api_router.register(r"correspondents", CorrespondentViewSet)
|
api_router.register(r"correspondents", CorrespondentViewSet)
|
||||||
api_router.register(r"document_types", DocumentTypeViewSet)
|
api_router.register(r"document_types", DocumentTypeViewSet)
|
||||||
|
|||||||
+10
-29
@@ -14,16 +14,14 @@ if TYPE_CHECKING:
|
|||||||
from llama_index.llms.openai_like import OpenAILike
|
from llama_index.llms.openai_like import OpenAILike
|
||||||
|
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless.network import GuardedAsyncHTTPTransport
|
from paperless.network import PinnedHostAsyncHTTPTransport
|
||||||
from paperless.network import GuardedHTTPTransport
|
from paperless.network import PinnedHostHTTPTransport
|
||||||
from paperless.network import OutboundRequestBlockedError
|
from paperless.network import create_pinned_async_httpx_client
|
||||||
from paperless.network import create_guarded_async_httpx_client
|
from paperless.network import create_pinned_httpx_client
|
||||||
from paperless.network import create_guarded_httpx_client
|
|
||||||
from paperless.network import validate_outbound_http_url
|
from paperless.network import validate_outbound_http_url
|
||||||
from paperless_ai.base_model import ClassificationSuggestions
|
from paperless_ai.base_model import ClassificationSuggestions
|
||||||
from paperless_ai.base_model import DocumentClassifierSchema
|
from paperless_ai.base_model import DocumentClassifierSchema
|
||||||
from paperless_ai.base_model import model_to_classification_suggestions
|
from paperless_ai.base_model import model_to_classification_suggestions
|
||||||
from paperless_ai.exceptions import LLMBlockedError
|
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
from paperless_ai.exceptions import LLMProviderError
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
@@ -45,19 +43,6 @@ LLM_SYSTEM_PROMPT = (
|
|||||||
PLACEHOLDER_API_KEY: Final = "fake"
|
PLACEHOLDER_API_KEY: Final = "fake"
|
||||||
|
|
||||||
|
|
||||||
def _find_blocked_cause(exc: BaseException) -> OutboundRequestBlockedError | None:
|
|
||||||
# The openai SDK wraps transport errors in APIConnectionError, so the
|
|
||||||
# block can sit anywhere in the __cause__ chain.
|
|
||||||
current: BaseException | None = exc
|
|
||||||
seen: set[int] = set()
|
|
||||||
while current is not None and id(current) not in seen:
|
|
||||||
if isinstance(current, OutboundRequestBlockedError):
|
|
||||||
return current
|
|
||||||
seen.add(id(current))
|
|
||||||
current = current.__cause__
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class AIClient:
|
class AIClient:
|
||||||
"""
|
"""
|
||||||
A client for interacting with an LLM backend.
|
A client for interacting with an LLM backend.
|
||||||
@@ -78,10 +63,10 @@ class AIClient:
|
|||||||
endpoint,
|
endpoint,
|
||||||
allow_internal=self.settings.llm_allow_internal_endpoints,
|
allow_internal=self.settings.llm_allow_internal_endpoints,
|
||||||
)
|
)
|
||||||
transport = GuardedHTTPTransport(
|
transport = PinnedHostHTTPTransport(
|
||||||
allow_internal=self.settings.llm_allow_internal_endpoints,
|
allow_internal=self.settings.llm_allow_internal_endpoints,
|
||||||
)
|
)
|
||||||
async_transport = GuardedAsyncHTTPTransport(
|
async_transport = PinnedHostAsyncHTTPTransport(
|
||||||
allow_internal=self.settings.llm_allow_internal_endpoints,
|
allow_internal=self.settings.llm_allow_internal_endpoints,
|
||||||
)
|
)
|
||||||
return Ollama(
|
return Ollama(
|
||||||
@@ -90,6 +75,7 @@ class AIClient:
|
|||||||
context_window=self.settings.llm_context_size,
|
context_window=self.settings.llm_context_size,
|
||||||
request_timeout=self.settings.llm_request_timeout,
|
request_timeout=self.settings.llm_request_timeout,
|
||||||
system_prompt=LLM_SYSTEM_PROMPT,
|
system_prompt=LLM_SYSTEM_PROMPT,
|
||||||
|
additional_kwargs=self.settings.llm_extra_params,
|
||||||
client=Client(
|
client=Client(
|
||||||
host=endpoint,
|
host=endpoint,
|
||||||
timeout=self.settings.llm_request_timeout,
|
timeout=self.settings.llm_request_timeout,
|
||||||
@@ -108,12 +94,12 @@ class AIClient:
|
|||||||
http_client = None
|
http_client = None
|
||||||
async_http_client = None
|
async_http_client = None
|
||||||
if endpoint:
|
if endpoint:
|
||||||
http_client = create_guarded_httpx_client(
|
http_client = create_pinned_httpx_client(
|
||||||
endpoint,
|
endpoint,
|
||||||
allow_internal=self.settings.llm_allow_internal_endpoints,
|
allow_internal=self.settings.llm_allow_internal_endpoints,
|
||||||
timeout=self.settings.llm_request_timeout,
|
timeout=self.settings.llm_request_timeout,
|
||||||
)
|
)
|
||||||
async_http_client = create_guarded_async_httpx_client(
|
async_http_client = create_pinned_async_httpx_client(
|
||||||
endpoint,
|
endpoint,
|
||||||
allow_internal=self.settings.llm_allow_internal_endpoints,
|
allow_internal=self.settings.llm_allow_internal_endpoints,
|
||||||
timeout=self.settings.llm_request_timeout,
|
timeout=self.settings.llm_request_timeout,
|
||||||
@@ -126,6 +112,7 @@ class AIClient:
|
|||||||
is_chat_model=True,
|
is_chat_model=True,
|
||||||
is_function_calling_model=True,
|
is_function_calling_model=True,
|
||||||
system_prompt=LLM_SYSTEM_PROMPT,
|
system_prompt=LLM_SYSTEM_PROMPT,
|
||||||
|
additional_kwargs=self.settings.llm_extra_params,
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
async_http_client=async_http_client,
|
async_http_client=async_http_client,
|
||||||
)
|
)
|
||||||
@@ -194,12 +181,6 @@ class AIClient:
|
|||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise LLMTimeoutError from exc
|
raise LLMTimeoutError from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
blocked = _find_blocked_cause(exc)
|
|
||||||
if blocked is not None:
|
|
||||||
raise LLMBlockedError(
|
|
||||||
"AI backend request was blocked by the outbound request "
|
|
||||||
f"policy: {blocked}",
|
|
||||||
) from exc
|
|
||||||
if self._is_openai_timeout(exc):
|
if self._is_openai_timeout(exc):
|
||||||
raise LLMTimeoutError from exc
|
raise LLMTimeoutError from exc
|
||||||
if self._is_provider_error(exc):
|
if self._is_provider_error(exc):
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ if TYPE_CHECKING:
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless.models import LLMEmbeddingBackend
|
from paperless.models import LLMEmbeddingBackend
|
||||||
from paperless.network import GuardedAsyncHTTPTransport
|
from paperless.network import PinnedHostAsyncHTTPTransport
|
||||||
from paperless.network import GuardedHTTPTransport
|
from paperless.network import PinnedHostHTTPTransport
|
||||||
from paperless.network import create_guarded_async_httpx_client
|
from paperless.network import create_pinned_async_httpx_client
|
||||||
from paperless.network import create_guarded_httpx_client
|
from paperless.network import create_pinned_httpx_client
|
||||||
from paperless.network import validate_outbound_http_url
|
from paperless.network import validate_outbound_http_url
|
||||||
from paperless_ai.client import PLACEHOLDER_API_KEY
|
from paperless_ai.client import PLACEHOLDER_API_KEY
|
||||||
|
|
||||||
@@ -29,19 +29,21 @@ def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
|
|||||||
http_client = None
|
http_client = None
|
||||||
async_http_client = None
|
async_http_client = None
|
||||||
if endpoint:
|
if endpoint:
|
||||||
http_client = create_guarded_httpx_client(
|
http_client = create_pinned_httpx_client(
|
||||||
endpoint,
|
endpoint,
|
||||||
allow_internal=config.llm_allow_internal_endpoints,
|
allow_internal=config.llm_allow_internal_endpoints,
|
||||||
timeout=config.llm_request_timeout,
|
timeout=config.llm_request_timeout,
|
||||||
)
|
)
|
||||||
async_http_client = create_guarded_async_httpx_client(
|
async_http_client = create_pinned_async_httpx_client(
|
||||||
endpoint,
|
endpoint,
|
||||||
allow_internal=config.llm_allow_internal_endpoints,
|
allow_internal=config.llm_allow_internal_endpoints,
|
||||||
timeout=config.llm_request_timeout,
|
timeout=config.llm_request_timeout,
|
||||||
)
|
)
|
||||||
return OpenAILikeEmbedding(
|
return OpenAILikeEmbedding(
|
||||||
model_name=config.llm_embedding_model or "text-embedding-3-small",
|
model_name=config.llm_embedding_model or "text-embedding-3-small",
|
||||||
api_key=config.llm_api_key or PLACEHOLDER_API_KEY,
|
api_key=config.llm_embedding_api_key
|
||||||
|
or config.llm_api_key
|
||||||
|
or PLACEHOLDER_API_KEY,
|
||||||
api_base=endpoint,
|
api_base=endpoint,
|
||||||
timeout=config.llm_request_timeout,
|
timeout=config.llm_request_timeout,
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
@@ -77,14 +79,14 @@ def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
|
|||||||
embedding._client = Client(
|
embedding._client = Client(
|
||||||
host=endpoint,
|
host=endpoint,
|
||||||
timeout=config.llm_request_timeout,
|
timeout=config.llm_request_timeout,
|
||||||
transport=GuardedHTTPTransport(
|
transport=PinnedHostHTTPTransport(
|
||||||
allow_internal=config.llm_allow_internal_endpoints,
|
allow_internal=config.llm_allow_internal_endpoints,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
embedding._async_client = AsyncClient(
|
embedding._async_client = AsyncClient(
|
||||||
host=endpoint,
|
host=endpoint,
|
||||||
timeout=config.llm_request_timeout,
|
timeout=config.llm_request_timeout,
|
||||||
transport=GuardedAsyncHTTPTransport(
|
transport=PinnedHostAsyncHTTPTransport(
|
||||||
allow_internal=config.llm_allow_internal_endpoints,
|
allow_internal=config.llm_allow_internal_endpoints,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,3 @@ class LLMTimeoutError(Exception):
|
|||||||
|
|
||||||
class LLMProviderError(Exception):
|
class LLMProviderError(Exception):
|
||||||
"""The LLM backend rejected the request."""
|
"""The LLM backend rejected the request."""
|
||||||
|
|
||||||
|
|
||||||
class LLMBlockedError(Exception):
|
|
||||||
"""The outbound request policy refused the connection to the LLM backend."""
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import ipaddress
|
|
||||||
import json
|
import json
|
||||||
from unittest.mock import ANY
|
from unittest.mock import ANY
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -10,15 +9,11 @@ import openai
|
|||||||
import pytest
|
import pytest
|
||||||
from llama_index.core.llms.llm import ToolSelection
|
from llama_index.core.llms.llm import ToolSelection
|
||||||
|
|
||||||
from paperless.network import BlockReason
|
|
||||||
from paperless.network import OutboundRequestBlockedError
|
|
||||||
from paperless_ai.client import LLM_SYSTEM_PROMPT
|
from paperless_ai.client import LLM_SYSTEM_PROMPT
|
||||||
from paperless_ai.client import PLACEHOLDER_API_KEY
|
from paperless_ai.client import PLACEHOLDER_API_KEY
|
||||||
from paperless_ai.client import AIClient
|
from paperless_ai.client import AIClient
|
||||||
from paperless_ai.exceptions import LLMBlockedError
|
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
from paperless_ai.exceptions import LLMProviderError
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
from paperless_testing.outbound import guard_of
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -28,6 +23,7 @@ def mock_ai_config():
|
|||||||
mock_config.llm_allow_internal_endpoints = True
|
mock_config.llm_allow_internal_endpoints = True
|
||||||
mock_config.llm_context_size = 8192
|
mock_config.llm_context_size = 8192
|
||||||
mock_config.llm_request_timeout = 120
|
mock_config.llm_request_timeout = 120
|
||||||
|
mock_config.llm_extra_params = {}
|
||||||
MockAIConfig.return_value = mock_config
|
MockAIConfig.return_value = mock_config
|
||||||
yield mock_config
|
yield mock_config
|
||||||
|
|
||||||
@@ -57,6 +53,7 @@ def test_get_llm_ollama(mock_ai_config, mock_ollama_llm):
|
|||||||
context_window=8192,
|
context_window=8192,
|
||||||
request_timeout=120,
|
request_timeout=120,
|
||||||
system_prompt=LLM_SYSTEM_PROMPT,
|
system_prompt=LLM_SYSTEM_PROMPT,
|
||||||
|
additional_kwargs={},
|
||||||
client=ANY,
|
client=ANY,
|
||||||
async_client=ANY,
|
async_client=ANY,
|
||||||
)
|
)
|
||||||
@@ -79,6 +76,7 @@ def test_get_llm_openai(mock_ai_config, mock_openai_llm):
|
|||||||
is_chat_model=True,
|
is_chat_model=True,
|
||||||
is_function_calling_model=True,
|
is_function_calling_model=True,
|
||||||
system_prompt=LLM_SYSTEM_PROMPT,
|
system_prompt=LLM_SYSTEM_PROMPT,
|
||||||
|
additional_kwargs={},
|
||||||
http_client=ANY,
|
http_client=ANY,
|
||||||
async_http_client=ANY,
|
async_http_client=ANY,
|
||||||
)
|
)
|
||||||
@@ -201,6 +199,36 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("backend", "llm_fixture"),
|
||||||
|
[
|
||||||
|
pytest.param("openai-like", "mock_openai_llm", id="openai-like"),
|
||||||
|
pytest.param("ollama", "mock_ollama_llm", id="ollama"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_llm_passes_extra_params(request, mock_ai_config, backend, llm_fixture):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Extra LLM params configured, e.g. for a provider that needs a
|
||||||
|
parameter we do not set ourselves
|
||||||
|
WHEN:
|
||||||
|
- The client builds the LLM
|
||||||
|
THEN:
|
||||||
|
- They are handed to the backend as additional_kwargs
|
||||||
|
"""
|
||||||
|
llm_mock = request.getfixturevalue(llm_fixture)
|
||||||
|
mock_ai_config.llm_backend = backend
|
||||||
|
mock_ai_config.llm_model = "gpt-5.6-luna"
|
||||||
|
mock_ai_config.llm_endpoint = "http://test-url"
|
||||||
|
mock_ai_config.llm_extra_params = {"reasoning_effort": "none"}
|
||||||
|
|
||||||
|
AIClient()
|
||||||
|
|
||||||
|
assert llm_mock.call_args.kwargs["additional_kwargs"] == {
|
||||||
|
"reasoning_effort": "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_run_llm_query_openai_timeout_raises_local_error(
|
def test_run_llm_query_openai_timeout_raises_local_error(
|
||||||
mock_ai_config,
|
mock_ai_config,
|
||||||
mock_openai_llm,
|
mock_openai_llm,
|
||||||
@@ -282,142 +310,3 @@ def test_run_llm_query_httpx_timeout_raises_local_error(
|
|||||||
|
|
||||||
with pytest.raises(LLMTimeoutError):
|
with pytest.raises(LLMTimeoutError):
|
||||||
client.run_llm_query("test_prompt")
|
client.run_llm_query("test_prompt")
|
||||||
|
|
||||||
|
|
||||||
class TestGuardedLLMClients:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("endpoint", "allow_internal"),
|
|
||||||
[
|
|
||||||
pytest.param("http://test-url", True, id="internal-allowed"),
|
|
||||||
pytest.param("http://93.184.216.34:11434", False, id="internal-blocked"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_ollama_clients_are_guarded(
|
|
||||||
self,
|
|
||||||
mock_ai_config: MagicMock,
|
|
||||||
mock_ollama_llm: MagicMock,
|
|
||||||
endpoint: str,
|
|
||||||
*,
|
|
||||||
allow_internal: bool,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- The Ollama backend
|
|
||||||
WHEN:
|
|
||||||
- The LLM is built
|
|
||||||
THEN:
|
|
||||||
- Its sync and async clients use guarded transports with the setting
|
|
||||||
"""
|
|
||||||
mock_ai_config.llm_backend = "ollama"
|
|
||||||
mock_ai_config.llm_model = "test_model"
|
|
||||||
mock_ai_config.llm_endpoint = endpoint
|
|
||||||
mock_ai_config.llm_allow_internal_endpoints = allow_internal
|
|
||||||
|
|
||||||
AIClient()
|
|
||||||
|
|
||||||
kwargs = mock_ollama_llm.call_args.kwargs
|
|
||||||
assert guard_of(kwargs["client"]._client)._allow_internal is allow_internal
|
|
||||||
assert (
|
|
||||||
guard_of(kwargs["async_client"]._client)._allow_internal is allow_internal
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("endpoint", "allow_internal"),
|
|
||||||
[
|
|
||||||
pytest.param("http://test-url", True, id="internal-allowed"),
|
|
||||||
pytest.param("http://93.184.216.34:8080", False, id="internal-blocked"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_openai_like_clients_are_guarded(
|
|
||||||
self,
|
|
||||||
mock_ai_config: MagicMock,
|
|
||||||
mock_openai_llm: MagicMock,
|
|
||||||
endpoint: str,
|
|
||||||
*,
|
|
||||||
allow_internal: bool,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- The OpenAI-like backend with an endpoint
|
|
||||||
WHEN:
|
|
||||||
- The LLM is built
|
|
||||||
THEN:
|
|
||||||
- Its sync and async http clients use guarded transports
|
|
||||||
"""
|
|
||||||
mock_ai_config.llm_backend = "openai-like"
|
|
||||||
mock_ai_config.llm_model = "test_model"
|
|
||||||
mock_ai_config.llm_api_key = "key"
|
|
||||||
mock_ai_config.llm_endpoint = endpoint
|
|
||||||
mock_ai_config.llm_allow_internal_endpoints = allow_internal
|
|
||||||
|
|
||||||
AIClient()
|
|
||||||
|
|
||||||
kwargs = mock_openai_llm.call_args.kwargs
|
|
||||||
assert guard_of(kwargs["http_client"])._allow_internal is allow_internal
|
|
||||||
assert guard_of(kwargs["async_http_client"])._allow_internal is allow_internal
|
|
||||||
|
|
||||||
|
|
||||||
def _block() -> OutboundRequestBlockedError:
|
|
||||||
return OutboundRequestBlockedError(
|
|
||||||
host="llm.example",
|
|
||||||
port=443,
|
|
||||||
reason=BlockReason.NON_PUBLIC_ADDRESS,
|
|
||||||
address=ipaddress.ip_address("10.0.0.1"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestBlockedLLMRequests:
|
|
||||||
def test_ollama_block_becomes_llm_blocked_error(
|
|
||||||
self,
|
|
||||||
mock_ai_config: MagicMock,
|
|
||||||
mock_ollama_llm: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- The Ollama backend and a connection blocked by policy
|
|
||||||
WHEN:
|
|
||||||
- An LLM query runs
|
|
||||||
THEN:
|
|
||||||
- LLMBlockedError is raised with a message, chained to the block
|
|
||||||
- The message, which tracked tasks store, names the destination but
|
|
||||||
not the resolved internal address
|
|
||||||
"""
|
|
||||||
mock_ai_config.llm_backend = "ollama"
|
|
||||||
mock_ai_config.llm_model = "test_model"
|
|
||||||
mock_ai_config.llm_endpoint = "http://test-url"
|
|
||||||
block = _block()
|
|
||||||
mock_ollama_llm.return_value.chat.side_effect = block
|
|
||||||
|
|
||||||
with pytest.raises(LLMBlockedError) as exc_info:
|
|
||||||
AIClient().run_llm_query("test_prompt")
|
|
||||||
|
|
||||||
assert exc_info.value.__cause__ is block
|
|
||||||
assert "llm.example:443" in str(exc_info.value)
|
|
||||||
assert "10.0.0.1" not in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_openai_wrapped_block_becomes_llm_blocked_error(
|
|
||||||
self,
|
|
||||||
mock_ai_config: MagicMock,
|
|
||||||
mock_openai_llm: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- The OpenAI-like backend, whose SDK wraps the block in
|
|
||||||
APIConnectionError
|
|
||||||
WHEN:
|
|
||||||
- An LLM query runs
|
|
||||||
THEN:
|
|
||||||
- LLMBlockedError is raised
|
|
||||||
"""
|
|
||||||
mock_ai_config.llm_backend = "openai-like"
|
|
||||||
mock_ai_config.llm_model = "test_model"
|
|
||||||
mock_ai_config.llm_api_key = "key"
|
|
||||||
mock_ai_config.llm_endpoint = "http://test-url"
|
|
||||||
wrapped = openai.APIConnectionError(
|
|
||||||
request=httpx.Request("POST", "http://test-url/v1/chat/completions"),
|
|
||||||
)
|
|
||||||
wrapped.__cause__ = _block()
|
|
||||||
mock_openai_llm.return_value.chat_with_tools.side_effect = wrapped
|
|
||||||
|
|
||||||
with pytest.raises(LLMBlockedError):
|
|
||||||
AIClient().run_llm_query("test_prompt")
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import cast
|
|
||||||
from unittest.mock import ANY
|
from unittest.mock import ANY
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from pytest_mock import MockerFixture
|
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from paperless.models import LLMEmbeddingBackend
|
from paperless.models import LLMEmbeddingBackend
|
||||||
@@ -15,15 +12,12 @@ from paperless_ai.embedding import _normalize_llm_index_text
|
|||||||
from paperless_ai.embedding import build_llm_index_text
|
from paperless_ai.embedding import build_llm_index_text
|
||||||
from paperless_ai.embedding import get_configured_model_name
|
from paperless_ai.embedding import get_configured_model_name
|
||||||
from paperless_ai.embedding import get_embedding_model
|
from paperless_ai.embedding import get_embedding_model
|
||||||
from paperless_testing.outbound import guard_of
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from llama_index.embeddings.ollama import OllamaEmbedding
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_ai_config():
|
def mock_ai_config():
|
||||||
with patch("paperless_ai.embedding.AIConfig") as MockAIConfig:
|
with patch("paperless_ai.embedding.AIConfig") as MockAIConfig:
|
||||||
|
MockAIConfig.return_value.llm_embedding_api_key = None
|
||||||
MockAIConfig.return_value.llm_embedding_endpoint = None
|
MockAIConfig.return_value.llm_embedding_endpoint = None
|
||||||
MockAIConfig.return_value.llm_allow_internal_endpoints = True
|
MockAIConfig.return_value.llm_allow_internal_endpoints = True
|
||||||
MockAIConfig.return_value.llm_context_size = 8192
|
MockAIConfig.return_value.llm_context_size = 8192
|
||||||
@@ -70,6 +64,7 @@ def mock_document():
|
|||||||
def test_get_embedding_model_openai(mock_ai_config):
|
def test_get_embedding_model_openai(mock_ai_config):
|
||||||
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
||||||
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
||||||
|
mock_ai_config.return_value.llm_embedding_api_key = "test_embedding_api_key"
|
||||||
mock_ai_config.return_value.llm_api_key = "test_api_key"
|
mock_ai_config.return_value.llm_api_key = "test_api_key"
|
||||||
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
||||||
|
|
||||||
@@ -79,7 +74,7 @@ def test_get_embedding_model_openai(mock_ai_config):
|
|||||||
model = get_embedding_model(mock_ai_config.return_value)
|
model = get_embedding_model(mock_ai_config.return_value)
|
||||||
MockOpenAIEmbedding.assert_called_once_with(
|
MockOpenAIEmbedding.assert_called_once_with(
|
||||||
model_name="text-embedding-3-small",
|
model_name="text-embedding-3-small",
|
||||||
api_key="test_api_key",
|
api_key="test_embedding_api_key",
|
||||||
api_base="http://test-url",
|
api_base="http://test-url",
|
||||||
timeout=120,
|
timeout=120,
|
||||||
http_client=ANY,
|
http_client=ANY,
|
||||||
@@ -88,6 +83,20 @@ def test_get_embedding_model_openai(mock_ai_config):
|
|||||||
assert model == MockOpenAIEmbedding.return_value
|
assert model == MockOpenAIEmbedding.return_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_embedding_model_openai_falls_back_to_llm_api_key(mock_ai_config):
|
||||||
|
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
||||||
|
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
||||||
|
mock_ai_config.return_value.llm_api_key = "test_api_key"
|
||||||
|
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"llama_index.embeddings.openai_like.OpenAILikeEmbedding",
|
||||||
|
) as MockOpenAIEmbedding:
|
||||||
|
get_embedding_model(mock_ai_config.return_value)
|
||||||
|
|
||||||
|
assert MockOpenAIEmbedding.call_args.kwargs["api_key"] == "test_api_key"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("configured_key", [None, ""])
|
@pytest.mark.parametrize("configured_key", [None, ""])
|
||||||
def test_get_embedding_model_openai_without_api_key_sends_placeholder(
|
def test_get_embedding_model_openai_without_api_key_sends_placeholder(
|
||||||
mock_ai_config,
|
mock_ai_config,
|
||||||
@@ -96,6 +105,7 @@ def test_get_embedding_model_openai_without_api_key_sends_placeholder(
|
|||||||
"""Same required key handling as the LLM client, see #13831."""
|
"""Same required key handling as the LLM client, see #13831."""
|
||||||
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
||||||
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
||||||
|
mock_ai_config.return_value.llm_embedding_api_key = configured_key
|
||||||
mock_ai_config.return_value.llm_api_key = configured_key
|
mock_ai_config.return_value.llm_api_key = configured_key
|
||||||
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
||||||
|
|
||||||
@@ -290,61 +300,3 @@ def test_normalize_llm_index_text_collapses_ocr_leaders_without_joining_lines():
|
|||||||
|
|
||||||
def test_normalize_llm_index_text_collapses_non_breaking_spaces():
|
def test_normalize_llm_index_text_collapses_non_breaking_spaces():
|
||||||
assert _normalize_llm_index_text("A\u00a0........\u00a0B") == "A B"
|
assert _normalize_llm_index_text("A\u00a0........\u00a0B") == "A B"
|
||||||
|
|
||||||
|
|
||||||
class TestGuardedEmbeddingClients:
|
|
||||||
def test_ollama_embedding_clients_are_guarded(
|
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
mock_ai_config: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- The Ollama embedding backend
|
|
||||||
WHEN:
|
|
||||||
- The embedding model is built
|
|
||||||
THEN:
|
|
||||||
- The clients swapped onto it use guarded transports
|
|
||||||
"""
|
|
||||||
config = mock_ai_config.return_value
|
|
||||||
config.llm_embedding_backend = LLMEmbeddingBackend.OLLAMA
|
|
||||||
config.llm_embedding_model = "embeddinggemma"
|
|
||||||
config.llm_endpoint = "http://93.184.216.34:11434"
|
|
||||||
config.llm_allow_internal_endpoints = False
|
|
||||||
|
|
||||||
mocker.patch("llama_index.embeddings.ollama.OllamaEmbedding")
|
|
||||||
|
|
||||||
model = cast("OllamaEmbedding", get_embedding_model(config))
|
|
||||||
|
|
||||||
assert guard_of(model._client._client)._allow_internal is False
|
|
||||||
assert guard_of(model._async_client._client)._allow_internal is False
|
|
||||||
|
|
||||||
def test_openai_like_embedding_clients_are_guarded(
|
|
||||||
self,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
mock_ai_config: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- The OpenAI-like embedding backend with an endpoint
|
|
||||||
WHEN:
|
|
||||||
- The embedding model is built
|
|
||||||
THEN:
|
|
||||||
- Its http clients use guarded transports
|
|
||||||
"""
|
|
||||||
config = mock_ai_config.return_value
|
|
||||||
config.llm_embedding_backend = LLMEmbeddingBackend.OPENAI_LIKE
|
|
||||||
config.llm_embedding_model = "text-embedding-3-small"
|
|
||||||
config.llm_api_key = "key"
|
|
||||||
config.llm_endpoint = "http://93.184.216.34:8080"
|
|
||||||
config.llm_allow_internal_endpoints = False
|
|
||||||
|
|
||||||
embedding_class = mocker.patch(
|
|
||||||
"llama_index.embeddings.openai_like.OpenAILikeEmbedding",
|
|
||||||
)
|
|
||||||
|
|
||||||
get_embedding_model(config)
|
|
||||||
|
|
||||||
kwargs = embedding_class.call_args.kwargs
|
|
||||||
assert guard_of(kwargs["http_client"])._allow_internal is False
|
|
||||||
assert guard_of(kwargs["async_http_client"])._allow_internal is False
|
|
||||||
|
|||||||
+21
-43
@@ -45,11 +45,8 @@ from documents.models import Correspondent
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.parsers import is_mime_type_supported
|
from documents.parsers import is_mime_type_supported
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
from paperless.network import HostResolutionError
|
from paperless.network import is_public_ip
|
||||||
from paperless.network import IPAddress
|
from paperless.network import resolve_hostname_ips
|
||||||
from paperless.network import OutboundRequestBlockedError
|
|
||||||
from paperless.network import blocked_message
|
|
||||||
from paperless.network import resolve_public_addresses
|
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_mail.models import ProcessedMail
|
from paperless_mail.models import ProcessedMail
|
||||||
@@ -448,34 +445,18 @@ class PinnedIMAP4(imaplib.IMAP4):
|
|||||||
|
|
||||||
Without pinned addresses, and with the ssl_context of the matching imaplib
|
Without pinned addresses, and with the ssl_context of the matching imaplib
|
||||||
class, this behaves exactly like imaplib.IMAP4 / imaplib.IMAP4_SSL.
|
class, this behaves exactly like imaplib.IMAP4 / imaplib.IMAP4_SSL.
|
||||||
|
|
||||||
``pinned_ips`` of ``None`` means no pinning was requested and the stock
|
|
||||||
imaplib connection path is used. An empty tuple means pinning was requested
|
|
||||||
and yielded nothing, and the connection fails without opening a socket
|
|
||||||
rather than falling back to a hostname lookup.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, host, port, pinned_ips, ssl_context=None, timeout=None) -> None:
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
port: int | None,
|
|
||||||
pinned_ips: tuple[IPAddress, ...] | None,
|
|
||||||
ssl_context: ssl.SSLContext | None = None,
|
|
||||||
timeout: float | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._pinned_ips = pinned_ips
|
self._pinned_ips = pinned_ips
|
||||||
self.ssl_context = ssl_context
|
self.ssl_context = ssl_context
|
||||||
super().__init__(host, port, timeout=timeout)
|
super().__init__(host, port, timeout=timeout)
|
||||||
|
|
||||||
def _connect_pinned(
|
def _connect_pinned(self, timeout):
|
||||||
self,
|
|
||||||
pinned_ips: tuple[IPAddress, ...],
|
|
||||||
timeout: float | None,
|
|
||||||
) -> socket.socket:
|
|
||||||
last_error: OSError | None = None
|
last_error: OSError | None = None
|
||||||
for ip in pinned_ips:
|
for ip_str in self._pinned_ips:
|
||||||
try:
|
try:
|
||||||
address = (str(ip), self.port)
|
address = (ip_str, self.port)
|
||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
return socket.create_connection(address, timeout)
|
return socket.create_connection(address, timeout)
|
||||||
return socket.create_connection(address)
|
return socket.create_connection(address)
|
||||||
@@ -483,9 +464,9 @@ class PinnedIMAP4(imaplib.IMAP4):
|
|||||||
last_error = e
|
last_error = e
|
||||||
raise last_error or OSError(f"Could not connect to {self.host}")
|
raise last_error or OSError(f"Could not connect to {self.host}")
|
||||||
|
|
||||||
def _create_socket(self, timeout: float | None) -> socket.socket:
|
def _create_socket(self, timeout):
|
||||||
if self._pinned_ips is not None:
|
if self._pinned_ips:
|
||||||
sock = self._connect_pinned(self._pinned_ips, timeout)
|
sock = self._connect_pinned(timeout)
|
||||||
else:
|
else:
|
||||||
sock = super()._create_socket(timeout)
|
sock = super()._create_socket(timeout)
|
||||||
if self.ssl_context is None:
|
if self.ssl_context is None:
|
||||||
@@ -496,12 +477,7 @@ class PinnedIMAP4(imaplib.IMAP4):
|
|||||||
class PinnedClientMixin:
|
class PinnedClientMixin:
|
||||||
"""Builds the imaplib client against the pre-resolved addresses, if any."""
|
"""Builds the imaplib client against the pre-resolved addresses, if any."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, *args, pinned_ips: list[str] | None, **kwargs) -> None:
|
||||||
self,
|
|
||||||
*args,
|
|
||||||
pinned_ips: tuple[IPAddress, ...] | None,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
self._pinned_ips = pinned_ips
|
self._pinned_ips = pinned_ips
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
@@ -539,20 +515,22 @@ class PinnedMailBoxStartTls(PinnedClientMixin, MailBoxStartTls):
|
|||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
def get_mailbox(
|
def get_mailbox(server, port, security) -> MailBox:
|
||||||
server: str,
|
|
||||||
port: int | None,
|
|
||||||
security: int,
|
|
||||||
) -> MailBox:
|
|
||||||
"""
|
"""
|
||||||
Returns the correct MailBox instance for the given configuration.
|
Returns the correct MailBox instance for the given configuration.
|
||||||
"""
|
"""
|
||||||
pinned_ips: tuple[IPAddress, ...] | None = None
|
pinned_ips: list[str] | None = None
|
||||||
if not settings.EMAIL_ALLOW_INTERNAL_HOSTS:
|
if not settings.EMAIL_ALLOW_INTERNAL_HOSTS:
|
||||||
try:
|
try:
|
||||||
pinned_ips = resolve_public_addresses(server, port)
|
pinned_ips = resolve_hostname_ips(server)
|
||||||
except (OutboundRequestBlockedError, HostResolutionError) as e:
|
except ValueError as e:
|
||||||
raise MailError(blocked_message(e)) from e
|
raise MailError(str(e)) from e
|
||||||
|
|
||||||
|
for ip_str in pinned_ips:
|
||||||
|
if not is_public_ip(ip_str):
|
||||||
|
raise MailError(
|
||||||
|
f"Connection blocked: {server} resolves to a non-public address",
|
||||||
|
)
|
||||||
|
|
||||||
ssl_context = ssl.create_default_context()
|
ssl_context = ssl.create_default_context()
|
||||||
if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: no cover
|
if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: no cover
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import factory
|
import factory
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from factory.django import DjangoModelFactory
|
|
||||||
|
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_mail.models import ProcessedMail
|
from paperless_mail.models import ProcessedMail
|
||||||
|
from paperless_testing.typed_factory import TypedModelFactory
|
||||||
|
|
||||||
|
|
||||||
class MailAccountFactory(DjangoModelFactory[MailAccount]):
|
class MailAccountFactory(TypedModelFactory[MailAccount]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MailAccount
|
model = MailAccount
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ class MailAccountFactory(DjangoModelFactory[MailAccount]):
|
|||||||
is_token = False
|
is_token = False
|
||||||
|
|
||||||
|
|
||||||
class MailRuleFactory(DjangoModelFactory[MailRule]):
|
class MailRuleFactory(TypedModelFactory[MailRule]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MailRule
|
model = MailRule
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ class MailRuleFactory(DjangoModelFactory[MailRule]):
|
|||||||
stop_processing = False
|
stop_processing = False
|
||||||
|
|
||||||
|
|
||||||
class ProcessedMailFactory(DjangoModelFactory[ProcessedMail]):
|
class ProcessedMailFactory(TypedModelFactory[ProcessedMail]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ProcessedMail
|
model = ProcessedMail
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import ipaddress
|
|
||||||
import socket
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -28,7 +25,6 @@ from documents.models import MatchingModel
|
|||||||
from paperless_mail import tasks
|
from paperless_mail import tasks
|
||||||
from paperless_mail.mail import MailAccountHandler
|
from paperless_mail.mail import MailAccountHandler
|
||||||
from paperless_mail.mail import MailError
|
from paperless_mail.mail import MailError
|
||||||
from paperless_mail.mail import PinnedIMAP4
|
|
||||||
from paperless_mail.mail import TagMailAction
|
from paperless_mail.mail import TagMailAction
|
||||||
from paperless_mail.mail import apply_mail_action
|
from paperless_mail.mail import apply_mail_action
|
||||||
from paperless_mail.mail import error_callback
|
from paperless_mail.mail import error_callback
|
||||||
@@ -1569,7 +1565,12 @@ class TestMail(
|
|||||||
("electronic", None, "invoices@mycompany.com", None, 1),
|
("electronic", None, "invoices@mycompany.com", None, 1),
|
||||||
(None, "amazon", "me@myselfandi.com", None, 1),
|
(None, "amazon", "me@myselfandi.com", None, 1),
|
||||||
]:
|
]:
|
||||||
with self.subTest(f_body=f_body, f_from=f_from, f_subject=f_subject):
|
with self.subTest(
|
||||||
|
f_body=f_body,
|
||||||
|
f_from=f_from,
|
||||||
|
f_to=f_to,
|
||||||
|
f_subject=f_subject,
|
||||||
|
):
|
||||||
MailRule.objects.all().delete()
|
MailRule.objects.all().delete()
|
||||||
_ = MailRule.objects.create(
|
_ = MailRule.objects.create(
|
||||||
name="testrule3",
|
name="testrule3",
|
||||||
@@ -1810,7 +1811,7 @@ class TestPostConsumeAction(TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
self.assertRaises(errors.ImapToolsError),
|
self.assertRaises(errors.ImapToolsError),
|
||||||
self.assertLogs("paperless.mail", level="ERROR") as cm,
|
self.assertLogs("paperless_mail", level="ERROR") as cm,
|
||||||
):
|
):
|
||||||
apply_mail_action(
|
apply_mail_action(
|
||||||
result=[],
|
result=[],
|
||||||
@@ -1819,6 +1820,7 @@ class TestPostConsumeAction(TestCase):
|
|||||||
message_subject=self.message_subject,
|
message_subject=self.message_subject,
|
||||||
message_date=self.message_date,
|
message_date=self.message_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
error_str = cm.output[0]
|
error_str = cm.output[0]
|
||||||
expected_str = "Error while processing mail action during post_consume"
|
expected_str = "Error while processing mail action during post_consume"
|
||||||
self.assertIn(expected_str, error_str)
|
self.assertIn(expected_str, error_str)
|
||||||
@@ -2049,13 +2051,10 @@ class TestMailAccountTestView(APITestCase):
|
|||||||
self.assertEqual(response.content.decode(), "Unable to connect to server")
|
self.assertEqual(response.content.decode(), "Unable to connect to server")
|
||||||
|
|
||||||
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
|
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
|
||||||
@mock.patch(
|
@mock.patch("paperless_mail.mail.resolve_hostname_ips", return_value=["127.0.0.1"])
|
||||||
"paperless.network._getaddrinfo",
|
|
||||||
return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 993))],
|
|
||||||
)
|
|
||||||
def test_mail_account_test_view_blocks_internal_host_when_disabled(
|
def test_mail_account_test_view_blocks_internal_host_when_disabled(
|
||||||
self,
|
self,
|
||||||
_mock_getaddrinfo: MagicMock,
|
_mock_resolve_hostname_ips,
|
||||||
) -> None:
|
) -> None:
|
||||||
data = {
|
data = {
|
||||||
"imap_server": "internal.example",
|
"imap_server": "internal.example",
|
||||||
@@ -2212,10 +2211,10 @@ class TestGetMailboxHostPinning(TestCase):
|
|||||||
|
|
||||||
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
|
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"paperless_mail.mail.resolve_public_addresses",
|
"paperless_mail.mail.resolve_hostname_ips",
|
||||||
return_value=(ipaddress.ip_address("93.184.216.34"),),
|
return_value=["93.184.216.34"],
|
||||||
)
|
)
|
||||||
def test_connects_to_validated_ip(self, _mock_resolve: MagicMock) -> None:
|
def test_connects_to_validated_ip(self, _mock_resolve) -> None:
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"paperless_mail.mail.socket.create_connection",
|
"paperless_mail.mail.socket.create_connection",
|
||||||
side_effect=OSError("no connection in tests"),
|
side_effect=OSError("no connection in tests"),
|
||||||
@@ -2232,13 +2231,10 @@ class TestGetMailboxHostPinning(TestCase):
|
|||||||
|
|
||||||
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
|
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"paperless_mail.mail.resolve_public_addresses",
|
"paperless_mail.mail.resolve_hostname_ips",
|
||||||
return_value=(ipaddress.ip_address("93.184.216.34"),),
|
return_value=["93.184.216.34"],
|
||||||
)
|
)
|
||||||
def test_ssl_pins_ip_but_keeps_hostname_for_sni(
|
def test_ssl_pins_ip_but_keeps_hostname_for_sni(self, _mock_resolve) -> None:
|
||||||
self,
|
|
||||||
_mock_resolve: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
ssl_context = mock.MagicMock()
|
ssl_context = mock.MagicMock()
|
||||||
ssl_context.wrap_socket.return_value.makefile.side_effect = OSError(
|
ssl_context.wrap_socket.return_value.makefile.side_effect = OSError(
|
||||||
"no connection in tests",
|
"no connection in tests",
|
||||||
@@ -2269,51 +2265,13 @@ class TestGetMailboxHostPinning(TestCase):
|
|||||||
|
|
||||||
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
|
@override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False)
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"paperless.network._getaddrinfo",
|
"paperless_mail.mail.resolve_hostname_ips",
|
||||||
return_value=[
|
return_value=["93.184.216.34", "127.0.0.1"],
|
||||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993)),
|
|
||||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 993)),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
def test_blocks_when_any_resolved_address_is_internal(
|
def test_blocks_when_any_resolved_address_is_internal(self, _mock_resolve) -> None:
|
||||||
self,
|
with self.assertRaises(MailError):
|
||||||
_mock_resolve: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A mail host resolving to one public and one loopback address
|
|
||||||
- EMAIL_ALLOW_INTERNAL_HOSTS is False
|
|
||||||
WHEN:
|
|
||||||
- A mailbox is requested
|
|
||||||
THEN:
|
|
||||||
- The whole host is blocked with the existing message
|
|
||||||
"""
|
|
||||||
with self.assertRaisesMessage(
|
|
||||||
MailError,
|
|
||||||
"Connection blocked: mail.example.com resolves to a non-public address",
|
|
||||||
):
|
|
||||||
get_mailbox("mail.example.com", 993, MailAccount.ImapSecurity.SSL)
|
get_mailbox("mail.example.com", 993, MailAccount.ImapSecurity.SSL)
|
||||||
|
|
||||||
def test_empty_pin_list_never_falls_back_to_hostname_lookup(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A pinned IMAP client given an empty tuple of addresses
|
|
||||||
WHEN:
|
|
||||||
- It connects
|
|
||||||
THEN:
|
|
||||||
- It fails without opening any socket, rather than resolving the
|
|
||||||
hostname itself
|
|
||||||
"""
|
|
||||||
with (
|
|
||||||
mock.patch("paperless_mail.mail.socket.create_connection") as pinned,
|
|
||||||
mock.patch("imaplib.IMAP4._create_socket") as unpinned,
|
|
||||||
self.assertRaises(OSError),
|
|
||||||
):
|
|
||||||
PinnedIMAP4("mail.example.com", 143, ())
|
|
||||||
|
|
||||||
pinned.assert_not_called()
|
|
||||||
unpinned.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestMailAccountProcess(APITestCase):
|
class TestMailAccountProcess(APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class PaperlessDirs:
|
|||||||
logging_dir: Path
|
logging_dir: Path
|
||||||
model_file: Path
|
model_file: Path
|
||||||
media_lock: Path
|
media_lock: Path
|
||||||
|
share_link_bundle_dir: Path
|
||||||
|
|
||||||
|
|
||||||
class DirSettings(TypedDict):
|
class DirSettings(TypedDict):
|
||||||
@@ -54,6 +55,7 @@ class DirSettings(TypedDict):
|
|||||||
STATIC_ROOT: Path
|
STATIC_ROOT: Path
|
||||||
MODEL_FILE: Path
|
MODEL_FILE: Path
|
||||||
MEDIA_LOCK: Path
|
MEDIA_LOCK: Path
|
||||||
|
SHARE_LINK_BUNDLE_DIR: Path
|
||||||
|
|
||||||
|
|
||||||
def build_paperless_dirs(root: Path) -> PaperlessDirs:
|
def build_paperless_dirs(root: Path) -> PaperlessDirs:
|
||||||
@@ -75,6 +77,7 @@ def build_paperless_dirs(root: Path) -> PaperlessDirs:
|
|||||||
logging_dir=data_dir / "log",
|
logging_dir=data_dir / "log",
|
||||||
model_file=data_dir / "classification_model.pickle",
|
model_file=data_dir / "classification_model.pickle",
|
||||||
media_lock=media_dir / "media.lock",
|
media_lock=media_dir / "media.lock",
|
||||||
|
share_link_bundle_dir=documents_dir / "share_link_bundles",
|
||||||
)
|
)
|
||||||
|
|
||||||
for directory in (
|
for directory in (
|
||||||
@@ -109,6 +112,7 @@ def dirs_settings(dirs: PaperlessDirs) -> DirSettings:
|
|||||||
STATIC_ROOT=dirs.static_dir,
|
STATIC_ROOT=dirs.static_dir,
|
||||||
MODEL_FILE=dirs.model_file,
|
MODEL_FILE=dirs.model_file,
|
||||||
MEDIA_LOCK=dirs.media_lock,
|
MEDIA_LOCK=dirs.media_lock,
|
||||||
|
SHARE_LINK_BUNDLE_DIR=dirs.share_link_bundle_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ Factory-boy factories for documents app models.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import factory
|
import factory
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth.models import User
|
||||||
from factory.django import DjangoModelFactory
|
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
@@ -15,11 +14,10 @@ from documents.models import MatchingModel
|
|||||||
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 paperless_testing.typed_factory import TypedModelFactory
|
||||||
UserModelT = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class CorrespondentFactory(DjangoModelFactory[Correspondent]):
|
class CorrespondentFactory(TypedModelFactory[Correspondent]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Correspondent
|
model = Correspondent
|
||||||
|
|
||||||
@@ -28,7 +26,7 @@ class CorrespondentFactory(DjangoModelFactory[Correspondent]):
|
|||||||
matching_algorithm = MatchingModel.MATCH_NONE
|
matching_algorithm = MatchingModel.MATCH_NONE
|
||||||
|
|
||||||
|
|
||||||
class DocumentTypeFactory(DjangoModelFactory[DocumentType]):
|
class DocumentTypeFactory(TypedModelFactory[DocumentType]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DocumentType
|
model = DocumentType
|
||||||
|
|
||||||
@@ -37,7 +35,7 @@ class DocumentTypeFactory(DjangoModelFactory[DocumentType]):
|
|||||||
matching_algorithm = MatchingModel.MATCH_NONE
|
matching_algorithm = MatchingModel.MATCH_NONE
|
||||||
|
|
||||||
|
|
||||||
class TagFactory(DjangoModelFactory[Tag]):
|
class TagFactory(TypedModelFactory[Tag]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
|
|
||||||
@@ -47,7 +45,7 @@ class TagFactory(DjangoModelFactory[Tag]):
|
|||||||
is_inbox_tag = False
|
is_inbox_tag = False
|
||||||
|
|
||||||
|
|
||||||
class StoragePathFactory(DjangoModelFactory[StoragePath]):
|
class StoragePathFactory(TypedModelFactory[StoragePath]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StoragePath
|
model = StoragePath
|
||||||
|
|
||||||
@@ -59,7 +57,7 @@ class StoragePathFactory(DjangoModelFactory[StoragePath]):
|
|||||||
matching_algorithm = MatchingModel.MATCH_NONE
|
matching_algorithm = MatchingModel.MATCH_NONE
|
||||||
|
|
||||||
|
|
||||||
class DocumentFactory(DjangoModelFactory[Document]):
|
class DocumentFactory(TypedModelFactory[Document]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Document
|
model = Document
|
||||||
|
|
||||||
@@ -71,9 +69,9 @@ class DocumentFactory(DjangoModelFactory[Document]):
|
|||||||
storage_path = None
|
storage_path = None
|
||||||
|
|
||||||
|
|
||||||
class UserFactory(DjangoModelFactory[UserModelT]):
|
class UserFactory(TypedModelFactory[User]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserModelT
|
model = User
|
||||||
|
|
||||||
username = factory.Sequence(lambda n: f"user{n}")
|
username = factory.Sequence(lambda n: f"user{n}")
|
||||||
is_staff = False
|
is_staff = False
|
||||||
@@ -88,7 +86,7 @@ class UserFactory(DjangoModelFactory[UserModelT]):
|
|||||||
staff = factory.Trait(is_staff=True)
|
staff = factory.Trait(is_staff=True)
|
||||||
|
|
||||||
|
|
||||||
class PaperlessTaskFactory(DjangoModelFactory[PaperlessTask]):
|
class PaperlessTaskFactory(TypedModelFactory[PaperlessTask]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PaperlessTask
|
model = PaperlessTask
|
||||||
|
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
"""
|
|
||||||
Real-socket helpers for tests of the outbound connection guard in
|
|
||||||
paperless.network: a local HTTP server, a per-hostname resolver fake and
|
|
||||||
spies recording which addresses were actually dialled.
|
|
||||||
|
|
||||||
The fixtures wrapping these live in the root conftest.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import http.server
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from dataclasses import field
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Any
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import anyio
|
|
||||||
import httpcore
|
|
||||||
|
|
||||||
from paperless.network import GuardedAsyncHTTPTransport
|
|
||||||
from paperless.network import GuardedHTTPTransport
|
|
||||||
from paperless.network import _GuardedAsyncBackend
|
|
||||||
from paperless.network import _GuardedSyncBackend
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
from unittest.mock import _Call
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from pytest_mock import MockerFixture
|
|
||||||
|
|
||||||
_REAL_GETADDRINFO = socket.getaddrinfo
|
|
||||||
_REAL_AGETADDRINFO = anyio.getaddrinfo
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ReceivedRequest:
|
|
||||||
method: str
|
|
||||||
path: str
|
|
||||||
headers: dict[str, str]
|
|
||||||
body: bytes
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LocalHTTPServer:
|
|
||||||
"""State of a threaded HTTP server bound to 127.0.0.1 on an ephemeral port."""
|
|
||||||
|
|
||||||
port: int
|
|
||||||
requests: list[ReceivedRequest] = field(default_factory=list)
|
|
||||||
connections: int = 0
|
|
||||||
redirect_to: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
||||||
# HTTP/1.1 keeps connections open, so tests can observe connection reuse.
|
|
||||||
# Every response sets Content-Length, which keep-alive requires.
|
|
||||||
protocol_version = "HTTP/1.1"
|
|
||||||
|
|
||||||
def _handle(self) -> None:
|
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
|
||||||
body = self.rfile.read(length) if length else b""
|
|
||||||
# BaseHTTPRequestHandler types server as the base socketserver.BaseServer;
|
|
||||||
# narrowing the attribute's declared type is a variance error, so the
|
|
||||||
# subclass is recovered here instead of on the class body.
|
|
||||||
server = cast("_RecordingHTTPServer", self.server)
|
|
||||||
state = server.state
|
|
||||||
state.requests.append(
|
|
||||||
ReceivedRequest(
|
|
||||||
method=self.command,
|
|
||||||
path=self.path,
|
|
||||||
headers={key.lower(): value for key, value in self.headers.items()},
|
|
||||||
body=body,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if state.redirect_to is not None:
|
|
||||||
self.send_response(302)
|
|
||||||
self.send_header("Location", state.redirect_to)
|
|
||||||
self.send_header("Content-Length", "0")
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Length", "2")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b"ok")
|
|
||||||
|
|
||||||
do_GET = _handle
|
|
||||||
do_POST = _handle
|
|
||||||
|
|
||||||
def log_message(self, format: str, *args: Any) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _RecordingHTTPServer(http.server.ThreadingHTTPServer):
|
|
||||||
daemon_threads = True
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__(("127.0.0.1", 0), _Handler)
|
|
||||||
self.state = LocalHTTPServer(port=self.socket.getsockname()[1])
|
|
||||||
|
|
||||||
def verify_request(self, request: Any, client_address: Any) -> bool:
|
|
||||||
self.state.connections += 1
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def running_http_server() -> Iterator[LocalHTTPServer]:
|
|
||||||
"""Serve on 127.0.0.1 in a background thread until the block exits."""
|
|
||||||
server = _RecordingHTTPServer()
|
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
try:
|
|
||||||
yield server.state
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
thread.join(timeout=5)
|
|
||||||
|
|
||||||
|
|
||||||
def _addrinfo(address: str, port: int | None) -> tuple[Any, ...]:
|
|
||||||
if ":" in address:
|
|
||||||
return (socket.AF_INET6, socket.SOCK_STREAM, 6, "", (address, port or 0, 0, 0))
|
|
||||||
return (socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, port or 0))
|
|
||||||
|
|
||||||
|
|
||||||
class FakeDNS:
|
|
||||||
"""
|
|
||||||
Answers the guard's resolver hooks for registered names and delegates
|
|
||||||
every other name to the real resolver. The stock httpcore backends keep
|
|
||||||
using the unpatched socket.getaddrinfo.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._answers: dict[str, list[str]] = {}
|
|
||||||
self.lookups: list[str] = []
|
|
||||||
|
|
||||||
def add(self, hostname: str, *addresses: str) -> None:
|
|
||||||
self._answers[hostname] = list(addresses)
|
|
||||||
|
|
||||||
def getaddrinfo(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
port: int | None,
|
|
||||||
*args: Any,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> list[tuple[Any, ...]]:
|
|
||||||
self.lookups.append(host)
|
|
||||||
if host in self._answers:
|
|
||||||
return [_addrinfo(address, port) for address in self._answers[host]]
|
|
||||||
return list(_REAL_GETADDRINFO(host, port, *args, **kwargs))
|
|
||||||
|
|
||||||
async def agetaddrinfo(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
port: int | None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> list[tuple[Any, ...]]:
|
|
||||||
self.lookups.append(host)
|
|
||||||
if host in self._answers:
|
|
||||||
return [_addrinfo(address, port) for address in self._answers[host]]
|
|
||||||
return list(await _REAL_AGETADDRINFO(host, port, **kwargs))
|
|
||||||
|
|
||||||
|
|
||||||
def install_fake_dns(mocker: MockerFixture) -> FakeDNS:
|
|
||||||
"""Patch the guard's resolver hooks with a FakeDNS for the current test."""
|
|
||||||
dns = FakeDNS()
|
|
||||||
mocker.patch("paperless.network._getaddrinfo", new=dns.getaddrinfo)
|
|
||||||
mocker.patch("paperless.network._agetaddrinfo", new=dns.agetaddrinfo)
|
|
||||||
return dns
|
|
||||||
|
|
||||||
|
|
||||||
def _dialled_host(call: _Call) -> str:
|
|
||||||
# The spy sits on the class, so args[0] is the backend instance.
|
|
||||||
if "host" in call.kwargs:
|
|
||||||
return str(call.kwargs["host"])
|
|
||||||
return str(call.args[1])
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DialRecorder:
|
|
||||||
sync_spy: MagicMock
|
|
||||||
async_spy: MagicMock
|
|
||||||
|
|
||||||
def hosts(self) -> list[str]:
|
|
||||||
calls = [*self.sync_spy.call_args_list, *self.async_spy.call_args_list]
|
|
||||||
return [_dialled_host(call) for call in calls]
|
|
||||||
|
|
||||||
|
|
||||||
def install_dial_recorder(mocker: MockerFixture) -> DialRecorder:
|
|
||||||
"""Spy on the stock backends' connect_tcp for the current test."""
|
|
||||||
return DialRecorder(
|
|
||||||
sync_spy=mocker.spy(httpcore.SyncBackend, "connect_tcp"),
|
|
||||||
async_spy=mocker.spy(httpcore.AnyIOBackend, "connect_tcp"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def allow_all_addresses(mocker: MockerFixture) -> None:
|
|
||||||
"""Patch the guard's public-address check to accept every address.
|
|
||||||
|
|
||||||
Loopback and other private addresses pass just like a public one, for
|
|
||||||
tests that exercise something other than the address policy itself.
|
|
||||||
"""
|
|
||||||
mocker.patch("paperless.network.is_public_ip", return_value=True)
|
|
||||||
|
|
||||||
|
|
||||||
def guard_of(
|
|
||||||
client: httpx.Client | httpx.AsyncClient,
|
|
||||||
) -> _GuardedSyncBackend | _GuardedAsyncBackend:
|
|
||||||
"""Return the guard installed on a client's transport."""
|
|
||||||
transport = client._transport
|
|
||||||
assert isinstance(transport, GuardedHTTPTransport | GuardedAsyncHTTPTransport)
|
|
||||||
backend = transport._pool._network_backend
|
|
||||||
assert isinstance(backend, _GuardedSyncBackend | _GuardedAsyncBackend)
|
|
||||||
return backend
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
A DjangoModelFactory base whose calls are typed as the model they build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Any
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from factory.django import DjangoModelFactory
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class TypedModelFactory(DjangoModelFactory[T]):
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# factory-boy leaves Factory() unannotated, so mypy takes it to build a
|
||||||
|
# factory instance. At runtime it builds the model.
|
||||||
|
def __new__(cls, *args: Any, **kwargs: Any) -> T: ... # type: ignore[misc]
|
||||||
@@ -18,6 +18,9 @@ supported-markers = [
|
|||||||
"sys_platform == 'linux'",
|
"sys_platform == 'linux'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[options]
|
||||||
|
prerelease-mode = "disallow"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohappyeyeballs"
|
name = "aiohappyeyeballs"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -200,6 +203,61 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ast-serialize"
|
||||||
|
version = "0.11.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-timeout"
|
name = "async-timeout"
|
||||||
version = "5.0.1"
|
version = "5.0.1"
|
||||||
@@ -220,7 +278,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autobahn"
|
name = "autobahn"
|
||||||
version = "25.12.2"
|
version = "26.7.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cbor2" },
|
{ name = "cbor2" },
|
||||||
@@ -228,24 +286,35 @@ dependencies = [
|
|||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "hyperlink" },
|
{ name = "hyperlink" },
|
||||||
{ name = "msgpack", marker = "platform_python_implementation == 'CPython'" },
|
{ name = "msgpack", marker = "platform_python_implementation == 'CPython'" },
|
||||||
{ name = "py-ubjson" },
|
|
||||||
{ name = "txaio" },
|
{ name = "txaio" },
|
||||||
{ name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" },
|
{ name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" },
|
||||||
{ name = "ujson" },
|
{ name = "ujson" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/54/d5/9adf0f5b9eb244e58e898e9f3db4b00c09835ef4b6c37d491886e0376b4f/autobahn-25.12.2.tar.gz", hash = "sha256:754c06a54753aeb7e8d10c5cbf03249ad9e2a1a32bca8be02865c6f00628a98c", size = 13893652, upload-time = "2025-12-15T11:13:19.086Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/de/73/f109f563c27e048e45d135d81af19e6ca391e24905550b06bd1c9d674c57/autobahn-26.7.1.tar.gz", hash = "sha256:c6949a2c6eb95fb1c218837dbda0a59abbbebafb8b11098551c01a7061dfd245", size = 14056542, upload-time = "2026-07-15T19:14:01.246Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/23/923e4f11dc9d12b9f5a014f36d591c479d623d54dda3bdcbd688cd12f052/autobahn-25.12.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16df879672c60f1f3fe452138c80f0fd221b3cb2ee5a14390c80f33b994104c1", size = 2053413, upload-time = "2025-12-15T11:12:58.167Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/8c/381cdcab8016df2177adc93d25f84ca3a5fb8f8be4f9d784336416c7bee8/autobahn-26.7.1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3fe80550707f0affb5cb10f3e0f66ec7e6e52abb29edc66dd76734c2d7d51bf4", size = 1997747, upload-time = "2026-07-15T19:13:21.998Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/0d/3d39637a1e32f555ce5fabec4a723a035556ef918b14140faea05e7de902/autobahn-25.12.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffe28048ef96eb0f925f24c2569bd72332e120f4cb31cd6c40dd66718a5f85e", size = 2224850, upload-time = "2025-12-15T11:13:00.089Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/a9/9293c6c6bc8970f42c9675942de78f306e18eafa47edba52fd27f9dc71bd/autobahn-26.7.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00fb9acd8775eaa0e272f36b76db903f10de56478f6a72f0bd07ee882ae1f2b8", size = 2082284, upload-time = "2026-07-15T19:13:23.582Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/8d/36452c06cbcad6d04587aeb87dfa987ef94be4a427b9f2155783d166bd97/autobahn-25.12.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:220748f21e91bd4a538d2d3de640cc17ee30b79f1c04a6c3dcdef321d531ee1c", size = 2225453, upload-time = "2025-12-15T11:13:02.865Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/ba/7396cb42a9c59df20c350ea05f75e6f25f582b474dee82b8e32823b2711e/autobahn-26.7.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c9674eddd55ad3ebd733824789175e5fb90c88afd523de507569ba0fcd6853", size = 2254260, upload-time = "2026-07-15T19:13:24.894Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/30/ef9c47038e4e9257319d6e1b87668b3df360a0c488d66ccff9d11aaff6ba/autobahn-25.12.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:bc17f6cab9438156d2701c293c76fd02a144f9be0a992c065dfee1935ce4845b", size = 1960447, upload-time = "2025-12-15T11:13:05.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/64/19753442770662ff45c4fe48db6345ac6fa3100fbbd989241c074e38ea6f/autobahn-26.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:30fa714de5c9903ef64084d3a938d8a3bac0bb42f1532d5de22f34b04a1c4819", size = 3173653, upload-time = "2026-07-15T19:13:26.261Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/e4/f3d5cb70bc0b9b5523d940734b2e0a251510d051a50d2e723f321e890859/autobahn-25.12.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5297a782fc7d0a26842438ef1342549ceee29496cda52672ac44635c79eeb94", size = 2053955, upload-time = "2025-12-15T11:13:06.052Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/a4/b690f272427acf1e8ea03b146e559dc67ade10dd4e0cccacc1d4c011b141/autobahn-26.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c3362197f3b9d5b0df7f3365bd00dedba7ee8b649d652941377abe690d1c8b14", size = 3402880, upload-time = "2026-07-15T19:13:27.683Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/49/4e592a19ae58fd9c796821a882b22598fac295ede50f899cc9d14a0282b6/autobahn-25.12.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0c3f1d5dafda52f8dc962ab583b6f3473b7b7186cab082d05372ed43a8261a5", size = 2225441, upload-time = "2025-12-15T11:13:07.527Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/23/0769ef39e1cfb0bec15bacdd7f407aaedfda14c0ca3f7e818b856f2ed1a1/autobahn-26.7.1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6c9013e9aa9ea8a561c89d7be2709546b51fc7ac8fdf6cd71bc12a634672d9a8", size = 2000605, upload-time = "2026-07-15T19:13:30.391Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/b7/0a0e3ecb2af7e452f5f359d19bdc647cbc8658f3f498bfa3bf8545cf4768/autobahn-25.12.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c840ee136bfaf6560467160129b0b25a0e33c9a51e2b251e98c5474f27583915", size = 1960463, upload-time = "2025-12-15T11:13:10.183Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/ef/26833f38ecf3aef3ff0aa09feb12f5d472f7370104148a6b78e3c7afc286/autobahn-26.7.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd9ebe577dd1030f9a0c41d20dc00eca90d5cf338531abcb510d978825feeea", size = 2082844, upload-time = "2026-07-15T19:13:31.559Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/8b/4215ac49d6b793b592fb08698f3a0e21a59eb3520be7f7ed288fcb52d919/autobahn-25.12.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9abda5cf817c0f8a19a55a67a031adf2fc70ed351719b5bd9e6fa0f5f4bc8f89", size = 2225590, upload-time = "2025-12-15T11:13:11.367Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/4c/00553ee9d57ee11df47bc9867d120cfe721a73bec0b58fb3a3b91cd7c797/autobahn-26.7.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de491baa4cf52fb6d7f542e445c72d94ce52dc7aabb47b0b4d5191e452dd2c74", size = 2254852, upload-time = "2026-07-15T19:13:32.8Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/99/b4a3da42471d3ec36e2dca0c1a5368a079fed9f73b159ce3f049c4a4983b/autobahn-25.12.2-pp311-pypy311_pp73-macosx_15_0_arm64.whl", hash = "sha256:0c226329ddec154c6f3b491ea3e4713035f0326c96ebfd6b305bf90f27a2fba1", size = 1955357, upload-time = "2025-12-15T11:13:13.581Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/ca/7884f6ffb8410882df98cb939dea24225dd79e4f091ceb59f4b826e54f2f/autobahn-26.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9723561c820ed032fe5a8f1530cb6d5f91dc595eea6009f3b2de7044a3df892a", size = 3174235, upload-time = "2026-07-15T19:13:34.134Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/81/67f19dd7395a9f1123a1f071314f8d1c4879c1869adeb8d99a236e756ac0/autobahn-25.12.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f079393a7626eb448c8accf21151f5f206d02f8e9cee4313d62a5ca30a3aaed", size = 623173, upload-time = "2025-12-15T11:13:14.945Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/e7/c6704e8f6bef3aa552a851a34908a06d91317d35ca55ec04b5db14385c33/autobahn-26.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e81c86cf41adca8a56ca5621ecd9ba40037f6d90d338c334bd529c8ac94bd7b6", size = 3403687, upload-time = "2026-07-15T19:13:35.633Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/eb/857eab3d25e3b9cc9e7e741d6193808ad91de0befb38cf10658bd339c205/autobahn-25.12.2-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b3a6c7d54a9f0434a435d88b86555510e5d0a84aa87042e292f29f707cab237", size = 2178008, upload-time = "2025-12-15T11:13:15.881Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/92/2f6e57d9f9e6b86b9db362f58aaa6cfeadc2f3a6901ec95aab27ef232b5c/autobahn-26.7.1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2ce48214b28f73338fabe0c7fd13d222cfab9e1dd2ef11660293522f64e76727", size = 1987052, upload-time = "2026-07-15T19:13:38.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/6d/f170134468e276fa9ea57eb1ae41f9cc0dcd0228e9d501f370fa50c0ee31/autobahn-26.7.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5f285dce9b3dff3eb2ef6c818ac8ede24d96bd1edac340855170fc9825c38a9", size = 2082813, upload-time = "2026-07-15T19:13:39.686Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/ce/b735fa933e9ba4fa8c3f9aa9ae68b4e2d4aadb0a92b38ade30e37a7d4795/autobahn-26.7.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66ab6e034e54f8c473df1a6b8031a3db46c16deebaf0c9b66db3e9137d2fab5e", size = 2254818, upload-time = "2026-07-15T19:13:40.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/3e/57855f4f52aa0ee64c6d8210637d0d9847de4c1082e03ddd2ffafd853d2d/autobahn-26.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20b3eab7d483e93278f9d7345592eb6b465883a6e88c2823aad13c3f943452db", size = 682494, upload-time = "2026-07-15T19:13:42.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/3c/3944f17dd2a06aee7d0d9f1c37b5a94518434d13ba2c38e738d33ca10daf/autobahn-26.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cd840524ff190aee695a58e8acd8740ee66b4a0e6a58ccb41416ed2cbe48d43f", size = 3403666, upload-time = "2026-07-15T19:13:43.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/3e/200471878093a502f8e8078c1ca19fd82acc68ae9ac363e395170da6dbe2/autobahn-26.7.1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f3d1be925e3fb33fff5280c1bd02027047519812c400d3efa5477d3968686c94", size = 1987070, upload-time = "2026-07-15T19:13:47.112Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/d1/704f881fd2c52b056dc0f14e6d0d640b1f3ff43f3b84cf85d3631e3243f4/autobahn-26.7.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f8094b0c0fa29a4963ce12130b7932469f89afa0242ff858d2b26541a81005", size = 2082939, upload-time = "2026-07-15T19:13:48.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/27/84e76aec7abbcb502d4cd34ef5c859eaa19a3b707cda68ab7ce68478dd92/autobahn-26.7.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0fcf9c3ff6b9b2bc85d6e1814a94d1d941d62f7df36d350b523d77df85d66ea", size = 2254983, upload-time = "2026-07-15T19:13:49.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/81/a810732a10342c5d6b90d19f83fa2bc9b6126e7c0cda7c4df867e311aa2e/autobahn-26.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f82e5a113f6c1ff14cec99aa411f7da8fceec3dcd4647d7ebdfc0278811e14d", size = 3174295, upload-time = "2026-07-15T19:13:51.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/c6/4886fdaecfeda013e085288a9d83bad6f1ded9995b8088ca933d9ec37201/autobahn-26.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:919309cbe41b28b0a3028e7c6fed52aca9fd21639619f372d3270c44341b6bdc", size = 3403713, upload-time = "2026-07-15T19:13:52.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/14/6485c29ad06a6bd7b3017558f99f6f89dcaa6ef6641930b25d9247adba4c/autobahn-26.7.1-pp311-pypy311_pp73-macosx_15_0_arm64.whl", hash = "sha256:9088acf790caf8cfd86590cb2b749279256ee210198f41d9858a38d1346e56c9", size = 1981962, upload-time = "2026-07-15T19:13:55.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/46/cb6d09604417beacdf485b414a05efa18511b0e78ac5451b3655bee711fa/autobahn-26.7.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea4548ee15c6bdf8aa0a1e81bf47b42db350f2bef69f83bace27a95ed0d21276", size = 655967, upload-time = "2026-07-15T19:13:57.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/d9/b846bc5a37f25ac147879d6451c466968a54efbf9d0467f0732f37c6f3f3/autobahn-26.7.1-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4ee0fe13a5218831d60863becd8c3cf6558e6c5ccc5d9d0e722012bdb1459bf", size = 2207401, upload-time = "2026-07-15T19:13:58.392Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -319,18 +388,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bleach"
|
|
||||||
version = "6.4.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "webencodings" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -1062,7 +1119,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-stubs"
|
name = "django-stubs"
|
||||||
version = "6.0.2"
|
version = "6.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
@@ -1070,9 +1127,9 @@ dependencies = [
|
|||||||
{ name = "types-pyyaml" },
|
{ name = "types-pyyaml" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/03/b2/f0214d86180f937c8e3358ff831b20f0634d95bd77436b18861c647e15bc/django_stubs-6.0.2.tar.gz", hash = "sha256:56d43b5e3741563af0063e5b6283f908c625b0439aa06314268673699d1bdccd", size = 274742, upload-time = "2026-04-01T08:27:35.092Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/2b/d6/fb467f5f769b831680d8cb6705e4bbec335c79b328ff7e347e220e676da8/django_stubs-6.1.1.tar.gz", hash = "sha256:291fd79197416d191ec9f4e443bc6800b64dd5d41e476b91e679495d005f3d61", size = 295523, upload-time = "2026-09-14T08:46:24.686Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/e7/8f2aaa22eac7fa18db3aca0e7b651ccf5ac79a2021bf67e75a16934a7076/django_stubs-6.0.2-py3-none-any.whl", hash = "sha256:c3bc84d80421758f3b2ad9e1358e001d719388a8eb106e67c873e606216108d4", size = 538234, upload-time = "2026-04-01T08:27:33.411Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/26/911e381b1cee7fbb7f11f640ec4be4ec1b6ae49dc58ff2cb829cf12dc4b9/django_stubs-6.1.1-py3-none-any.whl", hash = "sha256:a913d0c35242cd820f638486118fd542d687b6a85104bea9cb7e635b52b889e3", size = 559401, upload-time = "2026-09-14T08:46:23.153Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -1082,15 +1139,15 @@ compatible-mypy = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-stubs-ext"
|
name = "django-stubs-ext"
|
||||||
version = "6.0.2"
|
version = "6.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/52/e0/f2e6caf627d176a51fba1ca9c34082c7ea10d3f521ff2c828532ca99fa91/django_stubs_ext-6.0.2.tar.gz", hash = "sha256:70b7b7ae837e7a6036e2facb64435550bf7cf8143c1a6e802864d4824ce6058c", size = 6751, upload-time = "2026-04-01T08:27:01.987Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/70/5f80a3acf99d26dea89e892d4573f0e650904e573708b5fbf3bfc9cec72c/django_stubs_ext-6.1.1.tar.gz", hash = "sha256:c35263135558d4c5d12b69e1f4c98632bf59bc3ab5118f7d4882d12800e3a7dd", size = 7217, upload-time = "2026-09-14T08:45:52.44Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/d2/9cb93cd1ef94ddc97c26c902ff75a859f5f154051fec98cf8242649b26ce/django_stubs_ext-6.0.2-py3-none-any.whl", hash = "sha256:b35bdec1995bf49765cc39fa89aa7c23f120a23d0cb0152ab7fb4e48ff7d667b", size = 10446, upload-time = "2026-04-01T08:27:00.847Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/c3/305a9f7611c0dc96d2a638bc3a67bdcde5a7faa5f4669188db018b8af6fc/django_stubs_ext-6.1.1-py3-none-any.whl", hash = "sha256:64aebb7ce2c327a4a2b75207d67034e879fd7edf027c93487be69da363dc79fa", size = 10853, upload-time = "2026-09-14T08:45:51.289Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1116,16 +1173,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "djangorestframework-stubs"
|
name = "djangorestframework-stubs"
|
||||||
version = "3.16.9"
|
version = "3.18.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django-stubs" },
|
{ name = "django-stubs" },
|
||||||
{ name = "types-pyyaml" },
|
{ name = "types-pyyaml" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/30/84/fa0e31f763ee35152a418c2a456efdd8047a9da0f5909110147b70382191/djangorestframework_stubs-3.16.9.tar.gz", hash = "sha256:b1abb97490c90c85eabcd09b8ecbadae1b9360f21ad3021abf830227c0129697", size = 32798, upload-time = "2026-03-31T22:40:23.626Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6b/71/a7727b07e0433da02fb1ea22994f492ecb437a1fe7be5bf952dbb545a0f5/djangorestframework_stubs-3.18.1.tar.gz", hash = "sha256:65ed4a9ea094616759a2cba4fe0ad85efe25b471b28281b790bfefb46704c64a", size = 32439, upload-time = "2026-08-25T22:49:27.506Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/be/e53e3b89eaa30c21e036ae4d2ee88a92ef8cb43678400901748ddad870c5/djangorestframework_stubs-3.16.9-py3-none-any.whl", hash = "sha256:27b3e245d5f9c22ff6988d9e54388249f98f88608cc2b365b71e9f39dd096958", size = 57239, upload-time = "2026-03-31T22:40:22.314Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/1c/e266828fe2b78484651667976f8539e47b4e511a266424b890f613c6b7ad/djangorestframework_stubs-3.18.1-py3-none-any.whl", hash = "sha256:a2021f45f7759b493504c2a721f112ec4fd677753590c1ba346f4ebfb49a7f03", size = 55685, upload-time = "2026-08-25T22:49:26.178Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -2056,60 +2113,97 @@ sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "librt"
|
name = "librt"
|
||||||
version = "0.8.1"
|
version = "0.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
|
{ url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
|
{ url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2626,42 +2720,48 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "1.20.2"
|
version = "2.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "ast-serialize" },
|
||||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
{ name = "mypy-extensions" },
|
{ name = "mypy-extensions" },
|
||||||
{ name = "pathspec" },
|
{ name = "pathspec" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2874,10 +2974,8 @@ name = "paperless-ngx"
|
|||||||
version = "3.2.1"
|
version = "3.2.1"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
|
||||||
{ name = "azure-ai-documentintelligence" },
|
{ name = "azure-ai-documentintelligence" },
|
||||||
{ name = "babel" },
|
{ name = "babel" },
|
||||||
{ name = "bleach" },
|
|
||||||
{ name = "celery", extra = ["redis"] },
|
{ name = "celery", extra = ["redis"] },
|
||||||
{ name = "channels" },
|
{ name = "channels" },
|
||||||
{ name = "channels-redis" },
|
{ name = "channels-redis" },
|
||||||
@@ -2903,8 +3001,6 @@ dependencies = [
|
|||||||
{ name = "filelock" },
|
{ name = "filelock" },
|
||||||
{ name = "flower" },
|
{ name = "flower" },
|
||||||
{ name = "gotenberg-client", extra = ["httpx"] },
|
{ name = "gotenberg-client", extra = ["httpx"] },
|
||||||
{ name = "httpcore" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "httpx-oauth" },
|
{ name = "httpx-oauth" },
|
||||||
{ name = "ijson" },
|
{ name = "ijson" },
|
||||||
{ name = "imap-tools" },
|
{ name = "imap-tools" },
|
||||||
@@ -2936,6 +3032,7 @@ dependencies = [
|
|||||||
{ name = "tika-client", extra = ["httpx"] },
|
{ name = "tika-client", extra = ["httpx"] },
|
||||||
{ name = "torch", version = "2.14.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'linux'" },
|
{ name = "torch", version = "2.14.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'linux'" },
|
||||||
{ name = "torch", version = "2.14.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux'" },
|
{ name = "torch", version = "2.14.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux'" },
|
||||||
|
{ name = "turbohtml" },
|
||||||
{ name = "watchfiles" },
|
{ name = "watchfiles" },
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
{ name = "whoosh-compat", extra = ["tantivy"] },
|
{ name = "whoosh-compat", extra = ["tantivy"] },
|
||||||
@@ -3014,7 +3111,6 @@ typing = [
|
|||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "mypy-baseline" },
|
{ name = "mypy-baseline" },
|
||||||
{ name = "pyrefly" },
|
{ name = "pyrefly" },
|
||||||
{ name = "types-bleach" },
|
|
||||||
{ name = "types-channels" },
|
{ name = "types-channels" },
|
||||||
{ name = "types-colorama" },
|
{ name = "types-colorama" },
|
||||||
{ name = "types-dateparser" },
|
{ name = "types-dateparser" },
|
||||||
@@ -3029,10 +3125,8 @@ typing = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "anyio", specifier = ">=4.12" },
|
|
||||||
{ name = "azure-ai-documentintelligence", specifier = ">=1.0.2" },
|
{ name = "azure-ai-documentintelligence", specifier = ">=1.0.2" },
|
||||||
{ name = "babel", specifier = ">=2.17" },
|
{ name = "babel", specifier = ">=2.17" },
|
||||||
{ name = "bleach", specifier = "~=6.4.0" },
|
|
||||||
{ name = "celery", extras = ["redis"], specifier = "~=5.6.2" },
|
{ name = "celery", extras = ["redis"], specifier = "~=5.6.2" },
|
||||||
{ name = "channels", specifier = "~=4.2" },
|
{ name = "channels", specifier = "~=4.2" },
|
||||||
{ name = "channels-redis", specifier = "~=4.2" },
|
{ name = "channels-redis", specifier = "~=4.2" },
|
||||||
@@ -3059,8 +3153,6 @@ requires-dist = [
|
|||||||
{ name = "flower", specifier = ">=2.0.1,<2.2" },
|
{ name = "flower", specifier = ">=2.0.1,<2.2" },
|
||||||
{ 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" },
|
||||||
{ name = "httpcore", specifier = "~=1.0.9" },
|
|
||||||
{ name = "httpx", specifier = "~=0.28.1" },
|
|
||||||
{ name = "httpx-oauth", specifier = "~=0.17" },
|
{ name = "httpx-oauth", specifier = "~=0.17" },
|
||||||
{ name = "ijson", specifier = ">=3.5.1" },
|
{ name = "ijson", specifier = ">=3.5.1" },
|
||||||
{ name = "imap-tools", specifier = ">=1.14,<1.16" },
|
{ name = "imap-tools", specifier = ">=1.14,<1.16" },
|
||||||
@@ -3099,6 +3191,7 @@ requires-dist = [
|
|||||||
{ name = "tantivy", specifier = "~=0.26.0" },
|
{ name = "tantivy", specifier = "~=0.26.0" },
|
||||||
{ name = "tika-client", extras = ["httpx"], specifier = "~=1.0" },
|
{ name = "tika-client", extras = ["httpx"], specifier = "~=1.0" },
|
||||||
{ name = "torch", specifier = ">=2.13,<2.15", index = "https://download.pytorch.org/whl/cpu" },
|
{ name = "torch", specifier = ">=2.13,<2.15", index = "https://download.pytorch.org/whl/cpu" },
|
||||||
|
{ name = "turbohtml", specifier = "~=1.10.0" },
|
||||||
{ name = "watchfiles", specifier = ">=1.2" },
|
{ name = "watchfiles", specifier = ">=1.2" },
|
||||||
{ name = "whitenoise", specifier = "~=6.11" },
|
{ name = "whitenoise", specifier = "~=6.11" },
|
||||||
{ name = "whoosh-compat", extras = ["tantivy"], specifier = "==0.3" },
|
{ name = "whoosh-compat", extras = ["tantivy"], specifier = "==0.3" },
|
||||||
@@ -3159,7 +3252,6 @@ typing = [
|
|||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "mypy-baseline" },
|
{ name = "mypy-baseline" },
|
||||||
{ name = "pyrefly" },
|
{ name = "pyrefly" },
|
||||||
{ name = "types-bleach" },
|
|
||||||
{ name = "types-channels" },
|
{ name = "types-channels" },
|
||||||
{ name = "types-colorama" },
|
{ name = "types-colorama" },
|
||||||
{ name = "types-dateparser" },
|
{ name = "types-dateparser" },
|
||||||
@@ -3217,40 +3309,45 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pikepdf"
|
name = "pikepdf"
|
||||||
version = "10.2.0"
|
version = "10.13.0.post1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "deprecated" },
|
|
||||||
{ name = "lxml" },
|
{ name = "lxml" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/e9/a1462d6160805ca80c8f4aafc941aaf410a92d0fcc683706e94f499c2fac/pikepdf-10.2.0.tar.gz", hash = "sha256:0f398b0daeb2ffd2358f75c06f1dd47b9ba76f1a77dfe938cccf7080c58227d7", size = 4568506, upload-time = "2026-01-09T22:54:25.847Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/0e/6e74dd213537b71c945743a4b3112dbb430896ad68b8a6ad22e4468455d4/pikepdf-10.13.0.post1.tar.gz", hash = "sha256:4b73f926ebae81f04bf14527af330bd00bb268be767e0f189f7c4c3e4ad7ae0a", size = 4973186, upload-time = "2026-09-05T06:49:20.825Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/dc/aa7293763b603a9080ffab7ab87c7b571d637a389e9fb2ba839b864ca283/pikepdf-10.2.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:fb93732127d5183a91300af39e1cda5ded309e8439daec93536331a472b5e190", size = 4727891, upload-time = "2026-01-09T22:53:26.262Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/c1/48c9c0ed2ed88ca5d9cdd7f16075385a05a812d781b61bfa7f2d5182b247/pikepdf-10.13.0.post1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:98a7305e330f797da02b543d3ad57a134c4a14c6ec6f8d86d91aa9dd130c425b", size = 1846300, upload-time = "2026-09-05T06:48:11.563Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/dc/700c31f2c14f94d92483b10e1918390948ed20f6f572d82beb78ac5f94d0/pikepdf-10.2.0-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:ab7bd4629539cf2136a799dc3eaa2dfda59937035a97b0c5e22a7a3a4033cc49", size = 5030510, upload-time = "2026-01-09T22:53:28.023Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/20/484a3a61664132dc8c4bd97e0b8291fa79f9f4a3b1e1ffd5b67ac41ed98a/pikepdf-10.13.0.post1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:f3dedd02795626f17ee42d5c02ec4ec94e28aa47046ef430d4478454a8fbd07f", size = 1944179, upload-time = "2026-09-05T06:48:14.01Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/46/dc63364b05aa1913f2d7480cad62676bfb473065ba4b02d314dfd482f7dd/pikepdf-10.2.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f5623a5ba456d69dfeb86dc3bb3ec31ec1d120382d8c24804d1b430fce715ea", size = 2439498, upload-time = "2026-01-09T22:53:30.122Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/38/797df7d60352fc5ec3943c425acaa15d032cd2e673b516861f449536db57/pikepdf-10.13.0.post1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ba09ef5a5f26e38ee558d2a08223fee08a5ef1868962ae2d8590d4de3c8c92f", size = 2105054, upload-time = "2026-09-05T06:48:15.547Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/b6/1f9b8ca588fd34d9e3df49a80c62016e0b42ce6e580146c46d9728fdb6e8/pikepdf-10.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dec4d12f294df378d122ae441c27c1e76fb0d15b1e9d7374ae70c26604559bab", size = 2666945, upload-time = "2026-01-09T22:53:32.309Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/8a/1f003558c5c05cecf182af775839ad674fe23e06b314d0219b9d8422680a/pikepdf-10.13.0.post1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365b94f2be7e2857c6cb5445b56dc52dc7417ba9f06c8a4282d9f521cb2d0fb8", size = 2308617, upload-time = "2026-09-05T06:48:17.216Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/31/b1e61fac59f0b807edde655a821ff83bb041ae1500234c52ed1a2403c44a/pikepdf-10.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c0cebe3235232f1bd3c5f7956218ce92241c94223cb80eba837d372a40c61765", size = 3638109, upload-time = "2026-01-09T22:53:34.144Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/5b/0e7193ee8c7ca5b15f478918033a0fa78917b01249e1d4a4a65754644cd3/pikepdf-10.13.0.post1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f515a31c76cce043bbb7b781e77a343a4e26fa8f520ba337d30ddea0f7a0ce50", size = 3742337, upload-time = "2026-09-05T06:48:18.971Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/e9/a99bbf503c9d55e54553edff84ec67cac49d335fc33f3d5516c4746b6340/pikepdf-10.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0908e845c9140e245ad89a19fdfc6e5a6d82fcb505b8cc2c0ce81439ac4f064", size = 3829538, upload-time = "2026-01-09T22:53:36.341Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/f5/519e8728c04d05dcbd44d03b266a3f6acf8de3a0a6ed5ec0fa39ececddd7/pikepdf-10.13.0.post1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b63577c44fedf7ed6b971076f7c7ed0ff95a8bac627ac93b79e8b542214861a7", size = 3952988, upload-time = "2026-09-05T06:48:20.984Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/18/598383493a0f0f0c4eecd09b8fe06dddb9d326a89e2623a134d43e051485/pikepdf-10.2.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:18c35d00baff72bfae82d67028bedb02ea2b208e1af5545c23cd681f2487a279", size = 4737716, upload-time = "2026-01-09T22:53:39.973Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/a5/598e72c72ed46046e297f15763dffda88424870724a4a22f599b815cb774/pikepdf-10.13.0.post1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2c6e83f8a1828ec79cdec4df8cc07209eaf10ed7e4f5a90a7356b254bacc07d5", size = 1845515, upload-time = "2026-09-05T06:48:24.477Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/bec04784ba07d44f03b52ea524bcb7409bf7185ee8abec7ae29e3ac9e9ae/pikepdf-10.2.0-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:dd849d033b95de15965c095ebc4d78983099a11bb7b7897801dfaf3cb4083a35", size = 5042152, upload-time = "2026-01-09T22:53:41.808Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/b3/29691a5e9ee915357c081730d8cc02f35f19b4155857556fbe562a4d83ba/pikepdf-10.13.0.post1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:f2463f650efab46905b9e279f5c776faf96c65bb45c1acf9f2de0e8a6eec5fb7", size = 1944755, upload-time = "2026-09-05T06:48:26.332Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/3e/148b3c8e101c8ac3a33f41e86c5739413575495e471bce45ee228aafcbd6/pikepdf-10.2.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9910efdc7907af3da9e7b2a125a1f67d512165ffa623f62825deeb642669a7a", size = 2445796, upload-time = "2026-01-09T22:53:44.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4e/201f553b9405424d7aefa3be997f0a1c787ac3a089a844f1fc42f412fe0e/pikepdf-10.13.0.post1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9613505f5b22203465d4224a3fd8cf69876ce8278442c6478ac6849f54724a30", size = 2102202, upload-time = "2026-09-05T06:48:28.816Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/ef/b06f8fd68c34fed631cb8e3520dd955e59987de0eee6960dbc94bed11711/pikepdf-10.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0ec947e6429d7a3306153d32a0142462fdd8f905c5fe08c8a8e8c53b9c28a5c", size = 2693908, upload-time = "2026-01-09T22:53:46.039Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/a3/bd7e7b321e8bfe4b8530da57d12c557a259bbd4b40e739960a1f2ea507cb/pikepdf-10.13.0.post1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f80ca046d984752cf6f08093debc193884bee91c01c104aa0236767711a20f", size = 2307194, upload-time = "2026-09-05T06:48:31.558Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/bf/e5c40e9210e2ae8da7cad2cf6ae7d1db3b63a2916e6040645958e9ab4054/pikepdf-10.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b6383219a1cd31400403a69737a4e2a0c5d2a2c4cb9f380bcf45e33e8de802ea", size = 3643423, upload-time = "2026-01-09T22:53:48.333Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/b0/ca630f56015dfc6c4c8c81fdeb5f5099e7fca354f54a41dfe7f1382a5316/pikepdf-10.13.0.post1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f76fcbe5d86f2ae6f231ba542cd04793d4c89e75bd5f62526b7412926ff2100", size = 3740157, upload-time = "2026-09-05T06:48:33.285Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/1b/969dfb29dc9fd7b82fa7bc065df498e8a3e7ddb81e982140634ee539a8db/pikepdf-10.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d2f9ef5a84949bfc11152a323558f94cf85d9d97e9c510c061c7f803028f3f", size = 3854816, upload-time = "2026-01-09T22:53:50.158Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/48/7a84adc2fd14ec35e4b3d007575914de1ce0518e8c69b35ebee62fa2b142/pikepdf-10.13.0.post1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:092a9bf15739e931ecab15ec3baee5d9629dad90ea4e42b779f6b439d2d1e462", size = 3951601, upload-time = "2026-09-05T06:48:35.468Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/c5/e6f9e3407dd73ec570000a64747ff84e2f57b06b0477d1da6eaca5038162/pikepdf-10.2.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:09ff28d1de7fc7711a7ef8dfc40396d9243b64ee24c37cd1ab2a9f9827895caa", size = 4737680, upload-time = "2026-01-09T22:53:54.905Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/f4/3636368760840cbc3ee512330024dd6f518d583c1bbbb1b551ca8e18f5e8/pikepdf-10.13.0.post1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:87141ada970386ff6640db54f0bda734d3bde7960d3ba04a76768b48e25028ca", size = 1845524, upload-time = "2026-09-05T06:48:39.309Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/de/dffb785235ac2d930db86b215c1848d7258e625fa1949dd0633f8b72ab0a/pikepdf-10.2.0-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:62348b66e1401a4db0c64976b72dd74bb1a9eb3a33007a661500f4f8a64436bd", size = 5042150, upload-time = "2026-01-09T22:53:57.722Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/dc/7bbfba253a0394a237b81be371a64f904f99636d579ec78ef5d92025fd2d/pikepdf-10.13.0.post1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:55e53b4d8a4b1700f686f76e3a68411e421e962a4c8b1b90d00aab3f3e494a55", size = 1944829, upload-time = "2026-09-05T06:48:41.601Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/f0/4d883f57304d98650ade30a8c73fe593582d9afd9a7dada1f5f3f4cce362/pikepdf-10.2.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9e91780cb9ea3c6a350ffbcf03d5d95c30084d238afbd1d4b927cdb9e3649d", size = 2445446, upload-time = "2026-01-09T22:53:59.993Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/a3/10367bfb93501a151cbb96b6973f7c049fef04040aea35cf6cedf579c3e4/pikepdf-10.13.0.post1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fb8f82dc43056a4b4f891e78ee1db4e3ced75ba3e87b836f8e28c8771228928", size = 2102269, upload-time = "2026-09-05T06:48:43.222Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/65/ffe2555812a152d616accacea7c1c617c27a75590379ea7d9cc3a26bd92d/pikepdf-10.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52360a49a22e9353ec9a08ff5713cec8aacaf3ef960c704bc0a89ca8f050bdad", size = 2696242, upload-time = "2026-01-09T22:54:01.687Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/bd/a68b5d9b4aef4d4b9c374cfdfd941623f52303d31adea13554568df42abe/pikepdf-10.13.0.post1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3b58ccb30b93ba400e6a6a83315b4830eea49f6f19e18d78241fe6b1c49fec2", size = 2307160, upload-time = "2026-09-05T06:48:45.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/8c/2f937b0e2867cd48b523122e08753571fc9847978e239d7b5db9bd46879c/pikepdf-10.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4c1046939eb22c24c396deb37f8e0500caaa66b73114be55377d5554b4167", size = 3643730, upload-time = "2026-01-09T22:54:04.177Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/59/47bd86d9e338d301c28416d52d0e154a0d6322cf6b957c9df5f3d7828aa2/pikepdf-10.13.0.post1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6b038cd5bcbb6c1952bcc271695eaf24c4199d72e45606ee5e467f837760820e", size = 3739722, upload-time = "2026-09-05T06:48:47.105Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/17/f2919e4085c399e938bb945ea712dea70b3849e17cae6403f0cc1100e9ef/pikepdf-10.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3dcd8957a08e0a47f7a138904dca8cf73962fa17a096a47cd8bc33eb83a4f0a7", size = 3856645, upload-time = "2026-01-09T22:54:07.887Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/40/87fb6dddc9dce110429c72449e942174fd500f6fc4c9ff518a6b73057aa7/pikepdf-10.13.0.post1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b7b0cbb135de32ec3f41651a08ab294e3c18ae9fec32516a48d27e64a53a47f0", size = 3951549, upload-time = "2026-09-05T06:48:49.263Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/6e/846902abe8286d3b4ab70893e9ffbeec99aadd93ba1536cf471b222bb910/pikepdf-10.2.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:194c9a81ecb49e425a5cd5162621270b5e42cf05709d87eac018bd6f9ce98f80", size = 4733930, upload-time = "2026-01-09T22:54:11.931Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/0e/86897bf5325824c1f2d9d8be89839baf11011b5392d92620cb0273fb9af5/pikepdf-10.13.0.post1-cp314-abi3-macosx_14_0_arm64.whl", hash = "sha256:51fae4a4a3c6549aa4c405896ff7010f3e43e0c4f407c0bcee071ef13d271202", size = 1845245, upload-time = "2026-09-05T06:48:53.575Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/6d/abdbb794d2a512d4e828ef2014cc47ca263ad3fbd1b65f25f791b9c0bb1e/pikepdf-10.2.0-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:5adcf87dbfff4e1cd0a850db487274f474c94a6bf6347f3842c53da8d0eaa8df", size = 5042477, upload-time = "2026-01-09T22:54:13.708Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/ed/923846b7511627f8564d09345e083f14674dfb193de92d27f1cc4650602e/pikepdf-10.13.0.post1-cp314-abi3-macosx_15_0_x86_64.whl", hash = "sha256:8cb976331cb8b03ec3465e06d9e7a3eadbadb7e622be888e70f918ac732a105e", size = 1943659, upload-time = "2026-09-05T06:48:55.823Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/6c/6c42694fe1574a37aa2a40b4ba29a6713b4226436155ef7aa0bef649c117/pikepdf-10.2.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5de3cecbb35c4bc651e9326932974217be1d450d4a9840d77a592062eb507e27", size = 2448419, upload-time = "2026-01-09T22:54:15.6Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/bb/fcb09ad4bd227bbb37a7e5b24de86f9ce9d462aa0c7ee18781899bfa378a/pikepdf-10.13.0.post1-cp314-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e18d5a009bbe5f3ab18f916fb9e28f0c5b0d920736e3a85cc10c627ec633596", size = 2099423, upload-time = "2026-09-05T06:48:57.671Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/f4/aca3286aa37ace581afc8e3e0644a0cc55b9f9ceb31f28219d12ca11536c/pikepdf-10.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77868fd25182a45a4f3dec3c461aea8c696ef9565894c5cde4394bc8c32fb069", size = 2697600, upload-time = "2026-01-09T22:54:18.123Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/c9/707f9ba96727fa366a650237e46f0e20a73b244592eb6b97a49e401e2b43/pikepdf-10.13.0.post1-cp314-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:414f42c83e5e6029870de1a988625dafc95781ebff10e15d82caeb5e69a83c9c", size = 2303539, upload-time = "2026-09-05T06:48:59.506Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/a6/9135f9f0189634de61410573a0712d849e0157e3902e6b867339cc7dbf1b/pikepdf-10.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9a10e15e2f4d0bba36a2b4328342d00eff1a5a31399e1d1a93483c70d3c2b0e", size = 3647720, upload-time = "2026-01-09T22:54:20.158Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/15/79a2ccc354514a1321a161867a011fc917be158fc01a88da8c78ad18399d/pikepdf-10.13.0.post1-cp314-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f0eb89f06cad9231b9db54d81a22592b03b63924824a6b850febd2b75daa6546", size = 3737772, upload-time = "2026-09-05T06:49:01.245Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/60/f282077773a3321fad4cbfb16fe73ee3f8dd93b408df65c24779f12227c5/pikepdf-10.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a8f80ecf00fb15a760f218432a1046e7797cd14eaa6ccb52c8814ae8852745d8", size = 3859133, upload-time = "2026-01-09T22:54:22.358Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/85/a17440c2de64da71dc012b42e644b30ee4d98eb540c14d4e9f3538b73a9e/pikepdf-10.13.0.post1-cp314-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:544f1be1b1e5630a79cd182a8663c439504099eb9eae0d342a50173d9825bdf3", size = 3948342, upload-time = "2026-09-05T06:49:03.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/65/15a796a3cf9fb17d41acc1ab6719e7d3dcdf1260e76909d0dd32ecc97ba7/pikepdf-10.13.0.post1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:ef4ed47d40aa44deb063feb4a88e8bcf1c8fa0183ce526dc4295f7cbdb1292f8", size = 1853638, upload-time = "2026-09-05T06:49:07.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/f3/5d49a511fd13b59b94c5ad673695d331fce5d2846ab1501646c2a3b35b5f/pikepdf-10.13.0.post1-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:571efcd1d54e0dd817973c76c253feb6fb758c93bb0c16a893cc68f2a178d404", size = 1951768, upload-time = "2026-09-05T06:49:09.048Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/de/f6bbd9653695f6e2ed3494a439f11f3a89450c004fe9bf8ee583201ea759/pikepdf-10.13.0.post1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db9a18074ba112e7c517e8c21dfd8894cc13b8a37ccf49a5841192170fb68eb", size = 2107137, upload-time = "2026-09-05T06:49:10.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/aa/43b355681f05ea0b5808a26cba9e8e764686fa8dcebe0875db612664de40/pikepdf-10.13.0.post1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7962a75cf22d0d683b49ab19b8966e94dc7014d9aa6806f3c0d2b37fb9ae607", size = 2310991, upload-time = "2026-09-05T06:49:12.455Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/46/77574e9c4bded01afd7a3fe538f5432e396c3772c9bc1eab5d287aed00df/pikepdf-10.13.0.post1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b948e11f7dd3710f939194f00b4d75b0df87a30d53b31414128768ac25da77d8", size = 3744358, upload-time = "2026-09-05T06:49:14.428Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a8/4857df72cf4773553c2e6a82f93ee5e98c02f1c4e4877379e84d27384982/pikepdf-10.13.0.post1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99f6afccd6119233e7133bd4c2ade48461de3ddd4269cc28a4f90cdf7c1372f5", size = 3955964, upload-time = "2026-09-05T06:49:16.656Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3610,12 +3707,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "py-ubjson"
|
|
||||||
version = "0.16.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/28220d37e041fe1df03e857fe48f768dcd30cd151480bf6f00da8713214a/py-ubjson-0.16.1.tar.gz", hash = "sha256:b9bfb8695a1c7e3632e800fb83c943bf67ed45ddd87cd0344851610c69a5a482", size = 50316, upload-time = "2020-04-18T15:05:57.698Z" }
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyasn1"
|
name = "pyasn1"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -3817,16 +3908,18 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrefly"
|
name = "pyrefly"
|
||||||
version = "0.62.0"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/8874ed25781e7dd561c6d75fb4a7becf10a18d75b074f25b845cc334f781/pyrefly-0.62.0.tar.gz", hash = "sha256:da1fbe1075dc1e6c8e3134e9370b0a0e7a296061d782cca5bf83dbb8e4c10d7c", size = 5537672, upload-time = "2026-04-20T17:12:15.718Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/7e/082cc1b392318bacfbc087732524f3e089e9e7fe45b7645c4649b6f1f1e9/pyrefly-1.3.1.tar.gz", hash = "sha256:b774f1dfac82867fc16fe2175d80a630e2a72a6835d5b7e492568d6bfb6b05e7", size = 6679145, upload-time = "2026-09-15T05:09:34.49Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/ea/09bd9da7d5df294db800312fb415be2fefbaa5594178e9e49f44fa071aea/pyrefly-0.62.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d78ec4f126dee1fa76215b193b964490ce10e62a32d2787a72c51623658b803", size = 13020414, upload-time = "2026-04-20T17:11:43.617Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/bc/f09b5bd7eed796349bd93627aa2c3086df7ed0a114dafc3d2b2841599ee7/pyrefly-1.3.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a2a7dfed824fb066aa8a7c8d856c152b98e998fb84b954d5f892fb9834c0bd1", size = 15090117, upload-time = "2026-09-15T05:08:58.477Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/f0/f84afac4f220c4c8c801b779ee2ff28ad3f7731f4283c2e1b6ee9012e8c2/pyrefly-0.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a41a34902d20756264486f9e309f22633d100261bd960feea6e858a098d985d", size = 12515659, upload-time = "2026-04-20T17:11:46.59Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/24/a31e7de8e0f310c9f0753826d0df8b6a8ff86793941a6bd88ba24b735633/pyrefly-1.3.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37a1a1fb41e7190fe3964a877875dd809d6ee2887809e2c3d6e5f25a69805bfd", size = 14430773, upload-time = "2026-09-15T05:09:01.67Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/0b/620c39cefa9ae1b25ee7a2da9d8d3c278b095649cb8435c5e01ea64f7c17/pyrefly-0.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4666c6b65aea662e5f77b64dc91c091b7ea5cede6aa66c0f4cbae26480403583", size = 36228332, upload-time = "2026-04-20T17:11:50.523Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/e1/022352beb186f36d6213a95a975c42676ee418ff5e730629378d25564c5e/pyrefly-1.3.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b7650bc651a5a784701f2fb4f579da9a421c4d1b5fd91f9048f4529e46093c", size = 14877762, upload-time = "2026-09-15T05:09:04.767Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/fb/47b8b76438c12761e509a3666cd5a99d4af7f21976ba8385feb475cbfe30/pyrefly-0.62.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1aefab798f47d37c13ded791192fee9b39a6d2b12e31f38ae06a1f80c4b26e22", size = 38995741, upload-time = "2026-04-20T17:11:54.702Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/2c/d62a1b445aad1fd46375b83393b8a67da53883aeeba73d269ce71b3e8fd1/pyrefly-1.3.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad57f38bc73679c14a5f62995a2bd3bc43649b7270c858487e2eab78a6f95362", size = 16118225, upload-time = "2026-09-15T05:09:07.828Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/d2/03bd17673f61147cd5609cd7d6a1455eeccc17a07a7e141ed9931b0c42c0/pyrefly-0.62.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa986b50d56740da1d7ae7c660a505143cb9d286fa98cc7e5f4a759cc6eaa5d", size = 37205321, upload-time = "2026-04-20T17:11:58.9Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/67/3c3fa8a13e3ce8d8322ffb3627255a41a52261f17793e1a24e9214f10857/pyrefly-1.3.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3506b1252e9f5d755d0693c5a46d46b85579d36534f6e22a41eb8e098d0dde90", size = 16051197, upload-time = "2026-09-15T05:09:10.81Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/14/20ba7b7f2d182f9b7c1e24a3041dac9b5730ae28cfe1614a2c98706650f2/pyrefly-0.62.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32e9b175805c82ffb967e4708f4910bace7e1a12736907380cc9afdbaabb0efb", size = 41786834, upload-time = "2026-04-20T17:12:03.221Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/d1/84f81b9e32288b5d11c75e006f67e0f6b78b85da575673bd1a21524c8172/pyrefly-1.3.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d5a17c1ad655e5f93a6e8e0be90b8db28c6015fe07d6ed4e054891c9e14ef8", size = 15440062, upload-time = "2026-09-15T05:09:14.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/3c/2aa0c5ad6a2957a728bb38cfab70d1e9cafad21b0b1c1d92df74a3cf22bd/pyrefly-1.3.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c61e9dcf888beb81ca0e0c60ea4442640fdcf2f1f65bfe3d9f5d89b85516eb03", size = 14898534, upload-time = "2026-09-15T05:09:17.823Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/5d/2974f249fb2e285f966485719d977c5a2248cc4866403af94d935775ff15/pyrefly-1.3.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a78eed1c53f37807f0e2997be8edbe833b91eb9915f2cb553a1bdcd599e7105c", size = 15477758, upload-time = "2026-09-15T05:09:20.964Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5175,6 +5268,52 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/29/47/54eacf96b5c835bbd6ca631aa2740e7705ed63d9e3a8afd2d2cc6d09cae5/transformers-5.13.1-py3-none-any.whl", hash = "sha256:53f0ea8aa397e29244c2377ba981bcaf0c87adcf44fbdd447ef6306522afcacd", size = 11503977, upload-time = "2026-07-11T09:15:46.801Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/47/54eacf96b5c835bbd6ca631aa2740e7705ed63d9e3a8afd2d2cc6d09cae5/transformers-5.13.1-py3-none-any.whl", hash = "sha256:53f0ea8aa397e29244c2377ba981bcaf0c87adcf44fbdd447ef6306522afcacd", size = 11503977, upload-time = "2026-07-11T09:15:46.801Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "turbohtml"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/30/0b/94e323028f3aa652a5fb924681ed9c17537921716ac8b8267f03078c617f/turbohtml-1.10.0.tar.gz", hash = "sha256:a5f59f1f74b6b9060bc98751b1c7f68f5e74fd11e6252559eb838f948c8fc76d", size = 3497676, upload-time = "2026-09-24T21:58:12.43Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/13/a92f25cdfc64e333f3248fbf4ced0e65364be60279b90806670a8e78d0d9/turbohtml-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e61c74a0bab25f00ac000f7a4aac00db9834938dbda3dc364307a1f11f6a60c0", size = 1435697, upload-time = "2026-09-24T21:58:12.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/7b/3577e58777d837317dd57c65140f3a4e52cc5a1fac99a30ba9dc5c0b26c3/turbohtml-1.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:8ccb3c078e5300079e5b2a5cdd9bab6f866dad4818d4508679d71a8ad05f8a81", size = 1569438, upload-time = "2026-09-24T21:58:36.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/e1/b55930f6cb69ccb0ccb3877ff67c7ced8dc01cf2782a8c53264bbaaea532/turbohtml-1.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26502bcbea0926c9c9e468cfcf50381a5820a48e0c16b472271fe584abefd0f7", size = 1617559, upload-time = "2026-09-24T21:58:38.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/ec/a2aef28eb3dfceecc387b406aff28ee3c409ec6723b7c72d001b1a093708/turbohtml-1.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8363bc65c1671a013b8d7a3b3e5e54fa72d6b65630c11a08eac6a83556c64602", size = 1583921, upload-time = "2026-09-24T21:58:40.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/43/87fb508686714638ad8419dca027ca6e22977fc42dd66a98d2b4f2eefe90/turbohtml-1.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:84f40701d0a52a5b581f31e86223e2d40f311c9bc9d89264c730101ccd1f14a1", size = 1615781, upload-time = "2026-09-24T21:58:42.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/a8/2156eeb17eff5ee5d5f3f90cc634593010cd8282fb813025d6489301102b/turbohtml-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9addb768f79f93658030a128344b5458b242fa39cfa8611bc6e2cda3241e1deb", size = 1437483, upload-time = "2026-09-24T21:58:13.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/87/bbe8a0750c32e99bcefff6a6d98129599ebad68670013281bceeb748fc1a/turbohtml-1.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:423f589598a09bad4d4230ab8667363fccd0ae81cdd0e7bf7b02ad39d5923761", size = 1571298, upload-time = "2026-09-24T21:58:44Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/97/8759f26fb77e66ca76408798a390c078f76cba30b3fef895b83716394eb0/turbohtml-1.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4030eb81946491bb6d1a039a8beb8cc9455af6449d77a25db98b471929edb58b", size = 1619769, upload-time = "2026-09-24T21:58:47.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/9b/2dd565c20e7a962b26f2f1feb2a7513682551c4cbf7619480c3faa49999c/turbohtml-1.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:288b41d8e73466d0353f780acf30910ff39f82357d95752f9a85b0f09a71048f", size = 1586146, upload-time = "2026-09-24T21:58:51.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/25/174a1ba40cb738e8d06bc59d1884e95a955e16f02d9e81f020b55306c490/turbohtml-1.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a13ea122aaff4b78e1f7042f104f133ee88b8f48bf0c4cbf9dafd8f73ac47722", size = 1622290, upload-time = "2026-09-24T21:58:50.444Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/c2/d41f69814a0a217db1fd184da90ac3573094859b37aa8b73558fc2e1ac4a/turbohtml-1.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b9b6171d883daf85bb6374aeae6f7c19c489e3fc61e0643a7f2f1da9822a0679", size = 1437198, upload-time = "2026-09-24T21:58:15.536Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/fe/99913911376f99ff34a18bef342db8c662eee9965205f0345f64f46cfdf0/turbohtml-1.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:662e6b0650aaf30e090cca967dbc81fd4f1f3045be481a5e5e2715bf52578ef9", size = 1572058, upload-time = "2026-09-24T21:58:53.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/e0/2d293264e67078ee6b08f1cb7c93dd71da945e243d65f69672e14f4497f8/turbohtml-1.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:70380c063355c98b5ce120d8f201c7def00143e1bb6563ededc60f62807f0fe8", size = 1619317, upload-time = "2026-09-24T21:58:53.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/49/20046efdcbb99c1c8cbe7445cee5a12f53141d500056973e1379b981bcb9/turbohtml-1.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb5a788781d79099676bba5706973a65b75df9ed51ee3d069ef648a3dd0d6804", size = 1584857, upload-time = "2026-09-24T21:58:57.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/49/281e656c86a056adc97c0d99273ed92bc9b17dfed090520515856d0e9b59/turbohtml-1.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:225613d470dfb580d5346c7446fdcbfa13ccb2f4da2dd8d19000103250e625ab", size = 1620185, upload-time = "2026-09-24T21:58:56.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/c2/93b519997efdc353753945f053317b493648071b5b1be65f36e09f37bf61/turbohtml-1.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fab89d0ce289c7636cb825fa170c4b52b4083a5eda963576caff4cffad7bf8f9", size = 1437862, upload-time = "2026-09-24T21:58:17.11Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/8e/ff1443ccdf61791103ff6b0ed669059769bd4e97e536f337fce72b9dda3a/turbohtml-1.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:794f77b1dedcfb9eabfb4e17a1c0fafe047f54b7e2b6f3b920c45e9a36b5a885", size = 1572493, upload-time = "2026-09-24T21:58:59.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/8d/4917f89a01410ecfe66c785163f4c6e6f16cf1ce19de7cc8b9193fbf0c7a/turbohtml-1.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e85b2d554e5a45dd81a43d8e47996b445d8ab4aaaa777c894026a2cbc184a93e", size = 1620012, upload-time = "2026-09-24T21:58:58.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/c2/5b07960f1429a7f66448d0631807113c0b6c1d7e54aeef043f3e06d3f552/turbohtml-1.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3a0351e57de782a763a7a8c885fce8943e22b6facf72b0ecc95d5b44a681d3ab", size = 1587372, upload-time = "2026-09-24T21:59:01.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/8f/2a17171aeb3004f1c35aa0fdec0091e9f7b80282a7dbc2a647de69b6b094/turbohtml-1.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:be0ef006fb1b6099efe6d6591f09ae57d5bccc5faf2e6163ee6e3880424ef7f2", size = 1621259, upload-time = "2026-09-24T21:59:00.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/fb/bdc5a2fa1334f7878bbd72662445ca4f065af923229846786cca6fc317de/turbohtml-1.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:421f56edc437913379764ec58057d4f637e972e0b273ca808a091220c3cf7168", size = 1457281, upload-time = "2026-09-24T21:58:19.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/47/e8a585f1f0f05d20a6616aa73174bf8c9b65f5bf7c21b384ff854c480459/turbohtml-1.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:89c7d933c6e06b5977110680cb0ad01f8c1e7ee579c0539215831943ff65b4fd", size = 1564047, upload-time = "2026-09-24T21:59:04.097Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/65/f881a937aef5020186f4a9795c13669e540471668019603c9a04157632b8/turbohtml-1.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9b7164630c67d0ba241efe95651190b48de3b21d0d28f09c81405d6b6e4e3e3b", size = 1610056, upload-time = "2026-09-24T21:59:03.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/62/bb2654d5f826b8fccd3309b0d350724cc8ed77e2e0703d9a016182f0aed4/turbohtml-1.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92175e361a7e79b357d069a180452c5fdab3e83dbc8fe82af147ffd850efcb51", size = 1585075, upload-time = "2026-09-24T21:59:06.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/7e/8e2315ef3d96e0f57cc0881e7160373e824d895890e7757b682818d7122e/turbohtml-1.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a17ee91740811506c9d02b3c136d5676e6a328e243f026014bb813b484a5886c", size = 1625498, upload-time = "2026-09-24T21:59:05.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/5b/fef20b454ceaea7594550fbdd95377b0f7062cf34b7be45f52f85266c89b/turbohtml-1.10.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8a89b5996149b43ff9b83a9db8609044ccd557c9bf109d2ee4b36076e1ba6102", size = 1437864, upload-time = "2026-09-24T21:58:29.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/20/229e187d0e47eac34958313bdb4746feedb5f33c17f90da00573c82d8ec6/turbohtml-1.10.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:4f385ad6e14e50d59964b177e20348461eccda9decd59935f721cc3fccd5a238", size = 1574234, upload-time = "2026-09-24T21:59:07.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/e3/1882db0fdc8760d172ef19c11d8d973e339f775f450ba20568efb6bb7e24/turbohtml-1.10.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:01b9ef79d882e16402cefc19f78c0226f8b6f83c32588b8fe71733cd4bca83f1", size = 1618454, upload-time = "2026-09-24T21:59:07.158Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/53/ecd6314b50bc3683219be261259ec2e0585a301d910fdf4afa6b52b7af66/turbohtml-1.10.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:97bdde66856c1be748fd8cdb4c2a7840f578af7fcacd2e455bba390304b4cb0f", size = 1586289, upload-time = "2026-09-24T21:59:09.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/f8/264e99df411e1d8b0034cd606fac1145800b42547d8d44b508fa6857fcd0/turbohtml-1.10.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ffe72bff195a479e4811fb704d0fdaa33717893ebcf246f337d91c96e49eb8e7", size = 1621608, upload-time = "2026-09-24T21:59:09.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/4d/d4b5ab6fe192c56db95de48389a5fc6aa857295df2c282c01977bb97c0e5/turbohtml-1.10.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:702cc0c00754eca0531a7fdb804d98e36cdb79192bebd0a174016acc00e6dbe8", size = 1457298, upload-time = "2026-09-24T21:58:33.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/28/2c8eeea4ba04ab333a7c5911c52464952439785318d4781678281b50b176/turbohtml-1.10.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:5d45a01b10fd5eb4117e959f52349a621e7a154ba2d24ecbe25fb9c979abd9af", size = 1563687, upload-time = "2026-09-24T21:59:11.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/a9/399f4525643b7c4205359f26908487de1cbf8b520ff8ff38646342f01cab/turbohtml-1.10.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:08064d33db70a04231ef70cdf88fc038e778f9c6ef6c08e543244b2f7c40a156", size = 1610603, upload-time = "2026-09-24T21:59:10.984Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/3d/91ac95593de11942e9021cd2d6a7219b6afc345118d4e8d71fae9b88b0b0/turbohtml-1.10.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:19528d9ac387ebd89c8083adf77c194d1fc553c01a777fc1158db437b1ee23b7", size = 1583422, upload-time = "2026-09-24T21:59:13.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/b5/47a7b9abecd16df67addadb4ea01cc0cdb608b480234756b2a793731c0fa/turbohtml-1.10.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8806189da106986e3d3df695eca250dc935fcfafc077bd3ae7e111cd99b29498", size = 1624867, upload-time = "2026-09-24T21:59:13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/a6/6c0e7950d84306a470d523d3ec4cb6aee7942ed75056aa4fb3f0174d20f1/turbohtml-1.10.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:149e80b94b5a28b4799158306e7c8da2fca2cd55600374bb5c4d48403410d962", size = 1439722, upload-time = "2026-09-24T21:58:40.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/26/21425ccc18a0085cbc0f3ab7b9f0372273934ae3614b405a2a666eca4e3c/turbohtml-1.10.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab81284db98aa9a75e125dc88baf9b08ccd6ae628951435b31dccd65270a4e51", size = 1611998, upload-time = "2026-09-24T21:59:17.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/cf/ab65ae59b3c31add2cd4cddd6473720c5f6113fc9b3099fa80a119e9b9eb/turbohtml-1.10.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:47b2ec25547e5461cd9bef807701443588790c39472d0400d60f74ad6d2ba12f", size = 1664674, upload-time = "2026-09-24T21:59:16.917Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "twisted"
|
name = "twisted"
|
||||||
version = "26.4.0"
|
version = "26.4.0"
|
||||||
@@ -5223,18 +5362,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "types-bleach"
|
|
||||||
version = "6.4.0.20260607"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "types-html5lib" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/39/e7/07438d4d0bd58c69e79d0d8aa0320a2005b08d10cac209dd812b3609227c/types_bleach-6.4.0.20260607.tar.gz", hash = "sha256:62ff099b9d25fbcccb1402651c14a465a5f54a24138be987442d2eed38aca91c", size = 11686, upload-time = "2026-06-07T06:11:40.035Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/93/44da16cfa99a3f47fcb35e6b7396c94c3530e2028d2b1027cc4ce6d95d8d/types_bleach-6.4.0.20260607-py3-none-any.whl", hash = "sha256:fc07e2fd0ef9bc0a22026e8abe589d76c5d06ea885d70357263a4ff8c3dc8516", size = 11985, upload-time = "2026-06-07T06:11:38.996Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-cffi"
|
name = "types-cffi"
|
||||||
version = "1.17.0.20250915"
|
version = "1.17.0.20250915"
|
||||||
@@ -5278,18 +5405,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/5a/4fc8ea9c86e10465e820f920bc4983137c356b75b12b81574bb9b4d42605/types_dateparser-1.4.3.20260907-py3-none-any.whl", hash = "sha256:344c979fe7c0633f0a4f5098d7b26402a4969806ef7fc22f902d303f061593e1", size = 25729, upload-time = "2026-09-07T06:43:18.074Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/5a/4fc8ea9c86e10465e820f920bc4983137c356b75b12b81574bb9b4d42605/types_dateparser-1.4.3.20260907-py3-none-any.whl", hash = "sha256:344c979fe7c0633f0a4f5098d7b26402a4969806ef7fc22f902d303f061593e1", size = 25729, upload-time = "2026-09-07T06:43:18.074Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "types-html5lib"
|
|
||||||
version = "1.1.11.20251117"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "types-webencodings" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/f3/d9a1bbba7b42b5558a3f9fe017d967f5338cf8108d35991d9b15fdea3e0d/types_html5lib-1.1.11.20251117.tar.gz", hash = "sha256:1a6a3ac5394aa12bf547fae5d5eff91dceec46b6d07c4367d9b39a37f42f201a", size = 18100, upload-time = "2025-11-17T03:08:00.78Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/ab/f5606db367c1f57f7400d3cb3bead6665ee2509621439af1b29c35ef6f9e/types_html5lib-1.1.11.20251117-py3-none-any.whl", hash = "sha256:2a3fc935de788a4d2659f4535002a421e05bea5e172b649d33232e99d4272d08", size = 24302, upload-time = "2025-11-17T03:07:59.996Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-markdown"
|
name = "types-markdown"
|
||||||
version = "3.10.2.20260712"
|
version = "3.10.2.20260712"
|
||||||
@@ -5379,15 +5494,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2b/7f/016dc5cc718ec6ccaa84fb73ed409ef1c261793fd5e637cdfaa18beb40a9/types_setuptools-80.10.0.20260124-py3-none-any.whl", hash = "sha256:efed7e044f01adb9c2806c7a8e1b6aa3656b8e382379b53d5f26ee3db24d4c01", size = 64333, upload-time = "2026-01-24T03:18:38.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/7f/016dc5cc718ec6ccaa84fb73ed409ef1c261793fd5e637cdfaa18beb40a9/types_setuptools-80.10.0.20260124-py3-none-any.whl", hash = "sha256:efed7e044f01adb9c2806c7a8e1b6aa3656b8e382379b53d5f26ee3db24d4c01", size = 64333, upload-time = "2026-01-24T03:18:38.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "types-webencodings"
|
|
||||||
version = "0.5.0.20251108"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/66/d6/75e381959a2706644f02f7527d264de3216cf6ed333f98eff95954d78e07/types_webencodings-0.5.0.20251108.tar.gz", hash = "sha256:2378e2ceccced3d41bb5e21387586e7b5305e11519fc6b0659c629f23b2e5de4", size = 7470, upload-time = "2025-11-08T02:56:00.132Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/4e/8fcf33e193ce4af03c19d0e08483cf5f0838e883f800909c6bc61cb361be/types_webencodings-0.5.0.20251108-py3-none-any.whl", hash = "sha256:e21f81ff750795faffddaffd70a3d8bfff77d006f22c27e393eb7812586249d8", size = 8715, upload-time = "2025-11-08T02:55:59.456Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
@@ -5702,15 +5808,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f2/3e/45583b67c2ff08ad5a582d316fcb2f11d6cf0a50c7707ac09d212d25bc98/wcwidth-0.5.0-py3-none-any.whl", hash = "sha256:1efe1361b83b0ff7877b81ba57c8562c99cf812158b778988ce17ec061095695", size = 93772, upload-time = "2026-01-27T01:31:43.432Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/3e/45583b67c2ff08ad5a582d316fcb2f11d6cf0a50c7707ac09d212d25bc98/wcwidth-0.5.0-py3-none-any.whl", hash = "sha256:1efe1361b83b0ff7877b81ba57c8562c99cf812158b778988ce17ec061095695", size = 93772, upload-time = "2026-01-27T01:31:43.432Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webencodings"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whitenoise"
|
name = "whitenoise"
|
||||||
version = "6.12.0"
|
version = "6.12.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user