Merge branch 'nightly' into dependabot/pip/dnspython-2.6.1

This commit is contained in:
JonnyWong16 2024-04-18 13:02:32 -07:00 committed by GitHub
commit 5cbf0dfe7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
534 changed files with 63946 additions and 23064 deletions

View file

@ -27,12 +27,12 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v3
with: with:
config-file: ./.github/codeql-config.yml config-file: ./.github/codeql-config.yml
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v3
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"

View file

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Stale - name: Stale
uses: actions/stale@v8 uses: actions/stale@v9
with: with:
stale-issue-message: > stale-issue-message: >
This issue is stale because it has been open for 30 days with no activity. This issue is stale because it has been open for 30 days with no activity.
@ -30,7 +30,7 @@ jobs:
days-before-close: 5 days-before-close: 5
- name: Invalid Template - name: Invalid Template
uses: actions/stale@v8 uses: actions/stale@v9
with: with:
stale-issue-message: > stale-issue-message: >
Invalid issues template. Invalid issues template.

View file

@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Label Issues - name: Label Issues
uses: dessant/label-actions@v3 uses: dessant/label-actions@v4
with: with:
github-token: ${{ github.token }} github-token: ${{ github.token }}

View file

@ -47,7 +47,7 @@ jobs:
version: latest version: latest
- name: Cache Docker Layers - name: Cache Docker Layers
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}
@ -94,23 +94,10 @@ jobs:
if: always() && !contains(github.event.head_commit.message, '[skip ci]') if: always() && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
- name: Combine Job Status
id: status
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo "status=failure" >> $GITHUB_OUTPUT
else
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord - name: Post Status to Discord
uses: sarisia/actions-status-discord@v1 uses: sarisia/actions-status-discord@v1
with: with:
webhook: ${{ secrets.DISCORD_WEBHOOK }} webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ steps.status.outputs.status }} status: ${{ needs.build-docker.result == 'success' && 'success' || contains(needs.*.result, 'failure') && 'failure' || 'cancelled' }}
title: ${{ github.workflow }} title: ${{ github.workflow }}
nofail: true nofail: true

View file

@ -6,10 +6,13 @@ on:
branches: [master, beta, nightly] branches: [master, beta, nightly]
tags: [v*] tags: [v*]
env:
PYTHON_VERSION: '3.11'
jobs: jobs:
build-installer: build-installer:
name: Build ${{ matrix.os_upper }} Installer name: Build ${{ matrix.os_upper }} Installer
runs-on: ${{ matrix.os }}-latest runs-on: ${{ matrix.os }}-${{ matrix.os_version }}
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
strategy: strategy:
fail-fast: false fail-fast: false
@ -17,9 +20,13 @@ jobs:
include: include:
- os: 'windows' - os: 'windows'
os_upper: 'Windows' os_upper: 'Windows'
os_version: 'latest'
arch: 'x64'
ext: 'exe' ext: 'exe'
- os: 'macos' - os: 'macos'
os_upper: 'MacOS' os_upper: 'MacOS'
os_version: '14'
arch: 'universal'
ext: 'pkg' ext: 'pkg'
steps: steps:
@ -52,16 +59,16 @@ jobs:
echo $GITHUB_SHA > version.txt echo $GITHUB_SHA > version.txt
- name: Set Up Python - name: Set Up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v5
with: with:
python-version: '3.9' python-version: ${{ env.PYTHON_VERSION }}
cache: pip cache: pip
cache-dependency-path: '**/requirements*.txt' cache-dependency-path: '**/requirements*.txt'
- name: Install Dependencies - name: Install Dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r package/requirements-package.txt pip install -r package/requirements-package.txt --no-binary cffi
- name: Build Package - name: Build Package
run: | run: |
@ -74,7 +81,7 @@ jobs:
script-file: ./package/Tautulli.nsi script-file: ./package/Tautulli.nsi
arguments: > arguments: >
/DVERSION=${{ steps.get_version.outputs.VERSION_NSIS }} /DVERSION=${{ steps.get_version.outputs.VERSION_NSIS }}
/DINSTALLER_NAME=..\Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe /DINSTALLER_NAME=..\Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-${{ matrix.arch }}.${{ matrix.ext }}
additional-plugin-paths: package/nsis-plugins additional-plugin-paths: package/nsis-plugins
- name: Create MacOS Installer - name: Create MacOS Installer
@ -85,13 +92,13 @@ jobs:
--version ${{ steps.get_version.outputs.VERSION }} \ --version ${{ steps.get_version.outputs.VERSION }} \
--component ./dist/Tautulli.app \ --component ./dist/Tautulli.app \
--scripts ./package/macos-scripts \ --scripts ./package/macos-scripts \
Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.pkg Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-${{ matrix.arch }}.${{ matrix.ext }}
- name: Upload Installer - name: Upload Installer
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Tautulli-${{ matrix.os }}-installer name: Tautulli-${{ matrix.os }}-installer
path: Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.${{ matrix.ext }} path: Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-${{ matrix.arch }}.${{ matrix.ext }}
release: release:
name: Release Installers name: Release Installers
@ -99,9 +106,6 @@ jobs:
if: always() && startsWith(github.ref, 'refs/tags/') && !contains(github.event.head_commit.message, '[skip ci]') if: always() && startsWith(github.ref, 'refs/tags/') && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -111,8 +115,8 @@ jobs:
echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Download Installers - name: Download Installers
if: env.WORKFLOW_CONCLUSION == 'success' if: needs.build-installer.result == 'success'
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
- name: Get Changelog - name: Get Changelog
id: get_changelog id: get_changelog
@ -125,7 +129,7 @@ jobs:
echo "$EOF" >> $GITHUB_OUTPUT echo "$EOF" >> $GITHUB_OUTPUT
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
id: create_release id: create_release
env: env:
GITHUB_TOKEN: ${{ secrets.GHACTIONS_TOKEN }} GITHUB_TOKEN: ${{ secrets.GHACTIONS_TOKEN }}
@ -147,23 +151,10 @@ jobs:
if: always() && !contains(github.event.head_commit.message, '[skip ci]') if: always() && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
- name: Combine Job Status
id: status
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo "status=failure" >> $GITHUB_OUTPUT
else
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord - name: Post Status to Discord
uses: sarisia/actions-status-discord@v1 uses: sarisia/actions-status-discord@v1
with: with:
webhook: ${{ secrets.DISCORD_WEBHOOK }} webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ steps.status.outputs.status }} status: ${{ needs.build-installer.result == 'success' && 'success' || contains(needs.*.result, 'failure') && 'failure' || 'cancelled' }}
title: ${{ github.workflow }} title: ${{ github.workflow }}
nofail: true nofail: true

View file

@ -44,7 +44,7 @@ jobs:
architecture: ${{ matrix.architecture }} architecture: ${{ matrix.architecture }}
- name: Upload Snap Package - name: Upload Snap Package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Tautulli-snap-package-${{ matrix.architecture }} name: Tautulli-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }} path: ${{ steps.build.outputs.snap }}
@ -69,23 +69,10 @@ jobs:
if: always() && !contains(github.event.head_commit.message, '[skip ci]') if: always() && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
- name: Combine Job Status
id: status
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo "status=failure" >> $GITHUB_OUTPUT
else
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord - name: Post Status to Discord
uses: sarisia/actions-status-discord@v1 uses: sarisia/actions-status-discord@v1
with: with:
webhook: ${{ secrets.DISCORD_WEBHOOK }} webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ steps.status.outputs.status }} status: ${{ needs.build-snap.result == 'success' && 'success' || contains(needs.*.result, 'failure') && 'failure' || 'cancelled' }}
title: ${{ github.workflow }} title: ${{ github.workflow }}
nofail: true nofail: true

View file

@ -123,11 +123,6 @@
% else: % else:
<li><a href="graphs">Graphs</a></li> <li><a href="graphs">Graphs</a></li>
% endif % endif
% if title == "Synced Items":
<li class="active"><a href="sync">Synced Items</a></li>
% else:
<li><a href="sync">Synced Items</a></li>
% endif
% if title == "Settings": % if title == "Settings":
<li class="dropdown active"> <li class="dropdown active">
% else: % else:

View file

@ -369,7 +369,7 @@ DOCUMENTATION :: END
% if data['media_type'] != 'photo': % if data['media_type'] != 'photo':
<div class="dashboard-activity-info-time"> <div class="dashboard-activity-info-time">
% if data['live']: % if data['live']:
<br /><span class="thumb-tooltip dashboard-activity-info-channel" data-toggle="popover" data-img="${data['channel_thumb']}" data-height="40" data-width="40">${data['channel_call_sign']} ${data['channel_identifier']}</span> <br /><span class="thumb-tooltip dashboard-activity-info-channel" data-toggle="popover" data-img="${data['channel_thumb']}" data-height="40" data-width="40">${data['channel_title'] or (data['channel_vcn'] + ' ' + data['channel_call_sign'])}</span>
% elif data['view_offset']: % elif data['view_offset']:
ETA: ETA:
<span id="stream-eta-${sk}"> <span id="stream-eta-${sk}">

View file

@ -408,8 +408,8 @@ DOCUMENTATION :: END
% endif % endif
</div> </div>
<div class="summary-content-details-tag" id="channel-icon"> <div class="summary-content-details-tag" id="channel-icon">
% if media_info['channel_identifier']: % if media_info['channel_vcn']:
Channel <strong> <span class="thumb-tooltip" data-toggle="popover" data-img="${media_info['channel_thumb']}" data-height="40" data-width="40">${media_info['channel_call_sign']} ${media_info['channel_identifier']}</span> </strong> Channel <strong> <span class="thumb-tooltip" data-toggle="popover" data-img="${media_info['channel_thumb']}" data-height="40" data-width="40">${media_info['channel_title'] or (media_info['channel_vcn'] + ' ' + media_info['channel_call_sign'])}</span> </strong>
% endif % endif
</div> </div>
</div> </div>
@ -878,7 +878,7 @@ DOCUMENTATION :: END
transcode_decision: transcode_decision, transcode_decision: transcode_decision,
user_id: "${history_user_id}", user_id: "${history_user_id}",
% if data['live']: % if data['live']:
guid: "${data['guid']} guid: "${data['guid']}"
% elif data['media_type'] in ('show', 'artist'): % elif data['media_type'] in ('show', 'artist'):
grandparent_rating_key: "${data['rating_key']}" grandparent_rating_key: "${data['rating_key']}"
% elif data['media_type'] in ('season', 'album'): % elif data['media_type'] in ('season', 'album'):
@ -947,8 +947,12 @@ DOCUMENTATION :: END
url: 'item_watch_time_stats', url: 'item_watch_time_stats',
async: true, async: true,
data: { data: {
% if data['live']:
guid: "${data['guid']}"
% else:
rating_key: "${data['rating_key']}", rating_key: "${data['rating_key']}",
media_type: "${data['media_type']}" media_type: "${data['media_type']}"
% endif
}, },
complete: function(xhr, status) { complete: function(xhr, status) {
$("#watch-time-stats").html(xhr.responseText); $("#watch-time-stats").html(xhr.responseText);
@ -959,8 +963,12 @@ DOCUMENTATION :: END
url: 'item_user_stats', url: 'item_user_stats',
async: true, async: true,
data: { data: {
% if data['live']:
guid: "${data['guid']}"
% else:
rating_key: "${data['rating_key']}", rating_key: "${data['rating_key']}",
media_type: "${data['media_type']}" media_type: "${data['media_type']}"
% endif
}, },
complete: function(xhr, status) { complete: function(xhr, status) {
$("#user-stats").html(xhr.responseText); $("#user-stats").html(xhr.responseText);

View file

@ -76,7 +76,6 @@ DOCUMENTATION :: END
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
<li><a id="nav-tabs-export" href="#tabs-export" role="tab" data-toggle="tab">Export</a></li> <li><a id="nav-tabs-export" href="#tabs-export" role="tab" data-toggle="tab">Export</a></li>
% endif % endif
<li><a id="nav-tabs-synceditems" href="#tabs-synceditems" role="tab" data-toggle="tab">Synced Items</a></li>
<li><a id="nav-tabs-ipaddresses" href="#tabs-ipaddresses" role="tab" data-toggle="tab">IP Addresses</a></li> <li><a id="nav-tabs-ipaddresses" href="#tabs-ipaddresses" role="tab" data-toggle="tab">IP Addresses</a></li>
<li><a id="nav-tabs-tautullilogins" href="#tabs-tautullilogins" role="tab" data-toggle="tab">Tautulli Logins</a></li> <li><a id="nav-tabs-tautullilogins" href="#tabs-tautullilogins" role="tab" data-toggle="tab">Tautulli Logins</a></li>
</ul> </ul>
@ -316,57 +315,6 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
% endif % endif
<div role="tabpanel" class="tab-pane" id="tabs-synceditems">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-cloud-download"></i> Synced Items for <strong>
<span class="set-username">${data['friendly_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<div class="btn-group">
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="sync-row-edit-mode">
<i class="fa fa-trash-o"></i> Delete mode
</button>
</div>
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-syncs-button" id="refresh-syncs-list"><i class="fa fa-refresh"></i> Refresh synced items</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-sync"></div>
</div>
</div>
<div class="table-card-back">
<table class="display sync_table" id="sync_table-UID-${data['user_id']}" width="100%">
<thead>
<tr>
<th align="left" id="delete_row">Delete</th>
<th align="left" id="state">State</th>
<th align="left" id="username">Username</th>
<th align="left" id="sync_title">Title</th>
<th align="left" id="type">Type</th>
<th align="left" id="sync_platform">Platform</th>
<th align="left" id="device">Device</th>
<th align="left" id="size">Total Size</th>
<th align="left" id="items">Total Items</th>
<th align="left" id="converted">Converted</th>
<th align="left" id="downloaded">Downloaded</th>
<th align="left" id="sync_percent_complete">Complete</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-ipaddresses"> <div role="tabpanel" class="tab-pane" id="tabs-ipaddresses">
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
@ -642,12 +590,6 @@ DOCUMENTATION :: END
clearSearchButton('sync_table-UID-${data["user_id"]}', sync_table); clearSearchButton('sync_table-UID-${data["user_id"]}', sync_table);
} }
$('#nav-tabs-synceditems').on('shown.bs.tab', function() {
if (typeof(sync_table) === 'undefined') {
loadSyncTable(user_id);
}
});
$("#refresh-syncs-list").click(function() { $("#refresh-syncs-list").click(function() {
sync_table.ajax.reload(); sync_table.ajax.reload();
}); });

View file

@ -0,0 +1,396 @@
import math
import sys
from dataclasses import dataclass
from datetime import timezone
from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, SupportsFloat, SupportsIndex, TypeVar, Union
if sys.version_info < (3, 8):
from typing_extensions import Protocol, runtime_checkable
else:
from typing import Protocol, runtime_checkable
if sys.version_info < (3, 9):
from typing_extensions import Annotated, Literal
else:
from typing import Annotated, Literal
if sys.version_info < (3, 10):
EllipsisType = type(Ellipsis)
KW_ONLY = {}
SLOTS = {}
else:
from types import EllipsisType
KW_ONLY = {"kw_only": True}
SLOTS = {"slots": True}
__all__ = (
'BaseMetadata',
'GroupedMetadata',
'Gt',
'Ge',
'Lt',
'Le',
'Interval',
'MultipleOf',
'MinLen',
'MaxLen',
'Len',
'Timezone',
'Predicate',
'LowerCase',
'UpperCase',
'IsDigits',
'IsFinite',
'IsNotFinite',
'IsNan',
'IsNotNan',
'IsInfinite',
'IsNotInfinite',
'doc',
'DocInfo',
'__version__',
)
__version__ = '0.6.0'
T = TypeVar('T')
# arguments that start with __ are considered
# positional only
# see https://peps.python.org/pep-0484/#positional-only-arguments
class SupportsGt(Protocol):
def __gt__(self: T, __other: T) -> bool:
...
class SupportsGe(Protocol):
def __ge__(self: T, __other: T) -> bool:
...
class SupportsLt(Protocol):
def __lt__(self: T, __other: T) -> bool:
...
class SupportsLe(Protocol):
def __le__(self: T, __other: T) -> bool:
...
class SupportsMod(Protocol):
def __mod__(self: T, __other: T) -> T:
...
class SupportsDiv(Protocol):
def __div__(self: T, __other: T) -> T:
...
class BaseMetadata:
"""Base class for all metadata.
This exists mainly so that implementers
can do `isinstance(..., BaseMetadata)` while traversing field annotations.
"""
__slots__ = ()
@dataclass(frozen=True, **SLOTS)
class Gt(BaseMetadata):
"""Gt(gt=x) implies that the value must be greater than x.
It can be used with any type that supports the ``>`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
gt: SupportsGt
@dataclass(frozen=True, **SLOTS)
class Ge(BaseMetadata):
"""Ge(ge=x) implies that the value must be greater than or equal to x.
It can be used with any type that supports the ``>=`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
ge: SupportsGe
@dataclass(frozen=True, **SLOTS)
class Lt(BaseMetadata):
"""Lt(lt=x) implies that the value must be less than x.
It can be used with any type that supports the ``<`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
lt: SupportsLt
@dataclass(frozen=True, **SLOTS)
class Le(BaseMetadata):
"""Le(le=x) implies that the value must be less than or equal to x.
It can be used with any type that supports the ``<=`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
le: SupportsLe
@runtime_checkable
class GroupedMetadata(Protocol):
"""A grouping of multiple BaseMetadata objects.
`GroupedMetadata` on its own is not metadata and has no meaning.
All it the the constraint and metadata should be fully expressable
in terms of the `BaseMetadata`'s returned by `GroupedMetadata.__iter__()`.
Concrete implementations should override `GroupedMetadata.__iter__()`
to add their own metadata.
For example:
>>> @dataclass
>>> class Field(GroupedMetadata):
>>> gt: float | None = None
>>> description: str | None = None
...
>>> def __iter__(self) -> Iterable[BaseMetadata]:
>>> if self.gt is not None:
>>> yield Gt(self.gt)
>>> if self.description is not None:
>>> yield Description(self.gt)
Also see the implementation of `Interval` below for an example.
Parsers should recognize this and unpack it so that it can be used
both with and without unpacking:
- `Annotated[int, Field(...)]` (parser must unpack Field)
- `Annotated[int, *Field(...)]` (PEP-646)
""" # noqa: trailing-whitespace
@property
def __is_annotated_types_grouped_metadata__(self) -> Literal[True]:
return True
def __iter__(self) -> Iterator[BaseMetadata]:
...
if not TYPE_CHECKING:
__slots__ = () # allow subclasses to use slots
def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None:
# Basic ABC like functionality without the complexity of an ABC
super().__init_subclass__(*args, **kwargs)
if cls.__iter__ is GroupedMetadata.__iter__:
raise TypeError("Can't subclass GroupedMetadata without implementing __iter__")
def __iter__(self) -> Iterator[BaseMetadata]: # noqa: F811
raise NotImplementedError # more helpful than "None has no attribute..." type errors
@dataclass(frozen=True, **KW_ONLY, **SLOTS)
class Interval(GroupedMetadata):
"""Interval can express inclusive or exclusive bounds with a single object.
It accepts keyword arguments ``gt``, ``ge``, ``lt``, and/or ``le``, which
are interpreted the same way as the single-bound constraints.
"""
gt: Union[SupportsGt, None] = None
ge: Union[SupportsGe, None] = None
lt: Union[SupportsLt, None] = None
le: Union[SupportsLe, None] = None
def __iter__(self) -> Iterator[BaseMetadata]:
"""Unpack an Interval into zero or more single-bounds."""
if self.gt is not None:
yield Gt(self.gt)
if self.ge is not None:
yield Ge(self.ge)
if self.lt is not None:
yield Lt(self.lt)
if self.le is not None:
yield Le(self.le)
@dataclass(frozen=True, **SLOTS)
class MultipleOf(BaseMetadata):
"""MultipleOf(multiple_of=x) might be interpreted in two ways:
1. Python semantics, implying ``value % multiple_of == 0``, or
2. JSONschema semantics, where ``int(value / multiple_of) == value / multiple_of``
We encourage users to be aware of these two common interpretations,
and libraries to carefully document which they implement.
"""
multiple_of: Union[SupportsDiv, SupportsMod]
@dataclass(frozen=True, **SLOTS)
class MinLen(BaseMetadata):
"""
MinLen() implies minimum inclusive length,
e.g. ``len(value) >= min_length``.
"""
min_length: Annotated[int, Ge(0)]
@dataclass(frozen=True, **SLOTS)
class MaxLen(BaseMetadata):
"""
MaxLen() implies maximum inclusive length,
e.g. ``len(value) <= max_length``.
"""
max_length: Annotated[int, Ge(0)]
@dataclass(frozen=True, **SLOTS)
class Len(GroupedMetadata):
"""
Len() implies that ``min_length <= len(value) <= max_length``.
Upper bound may be omitted or ``None`` to indicate no upper length bound.
"""
min_length: Annotated[int, Ge(0)] = 0
max_length: Optional[Annotated[int, Ge(0)]] = None
def __iter__(self) -> Iterator[BaseMetadata]:
"""Unpack a Len into zone or more single-bounds."""
if self.min_length > 0:
yield MinLen(self.min_length)
if self.max_length is not None:
yield MaxLen(self.max_length)
@dataclass(frozen=True, **SLOTS)
class Timezone(BaseMetadata):
"""Timezone(tz=...) requires a datetime to be aware (or ``tz=None``, naive).
``Annotated[datetime, Timezone(None)]`` must be a naive datetime.
``Timezone[...]`` (the ellipsis literal) expresses that the datetime must be
tz-aware but any timezone is allowed.
You may also pass a specific timezone string or timezone object such as
``Timezone(timezone.utc)`` or ``Timezone("Africa/Abidjan")`` to express that
you only allow a specific timezone, though we note that this is often
a symptom of poor design.
"""
tz: Union[str, timezone, EllipsisType, None]
@dataclass(frozen=True, **SLOTS)
class Predicate(BaseMetadata):
"""``Predicate(func: Callable)`` implies `func(value)` is truthy for valid values.
Users should prefer statically inspectable metadata, but if you need the full
power and flexibility of arbitrary runtime predicates... here it is.
We provide a few predefined predicates for common string constraints:
``IsLower = Predicate(str.islower)``, ``IsUpper = Predicate(str.isupper)``, and
``IsDigit = Predicate(str.isdigit)``. Users are encouraged to use methods which
can be given special handling, and avoid indirection like ``lambda s: s.lower()``.
Some libraries might have special logic to handle certain predicates, e.g. by
checking for `str.isdigit` and using its presence to both call custom logic to
enforce digit-only strings, and customise some generated external schema.
We do not specify what behaviour should be expected for predicates that raise
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
skip invalid constraints, or statically raise an error; or it might try calling it
and then propogate or discard the resulting exception.
"""
func: Callable[[Any], bool]
@dataclass
class Not:
func: Callable[[Any], bool]
def __call__(self, __v: Any) -> bool:
return not self.func(__v)
_StrType = TypeVar("_StrType", bound=str)
LowerCase = Annotated[_StrType, Predicate(str.islower)]
"""
Return True if the string is a lowercase string, False otherwise.
A string is lowercase if all cased characters in the string are lowercase and there is at least one cased character in the string.
""" # noqa: E501
UpperCase = Annotated[_StrType, Predicate(str.isupper)]
"""
Return True if the string is an uppercase string, False otherwise.
A string is uppercase if all cased characters in the string are uppercase and there is at least one cased character in the string.
""" # noqa: E501
IsDigits = Annotated[_StrType, Predicate(str.isdigit)]
"""
Return True if the string is a digit string, False otherwise.
A string is a digit string if all characters in the string are digits and there is at least one character in the string.
""" # noqa: E501
IsAscii = Annotated[_StrType, Predicate(str.isascii)]
"""
Return True if all characters in the string are ASCII, False otherwise.
ASCII characters have code points in the range U+0000-U+007F. Empty string is ASCII too.
"""
_NumericType = TypeVar('_NumericType', bound=Union[SupportsFloat, SupportsIndex])
IsFinite = Annotated[_NumericType, Predicate(math.isfinite)]
"""Return True if x is neither an infinity nor a NaN, and False otherwise."""
IsNotFinite = Annotated[_NumericType, Predicate(Not(math.isfinite))]
"""Return True if x is one of infinity or NaN, and False otherwise"""
IsNan = Annotated[_NumericType, Predicate(math.isnan)]
"""Return True if x is a NaN (not a number), and False otherwise."""
IsNotNan = Annotated[_NumericType, Predicate(Not(math.isnan))]
"""Return True if x is anything but NaN (not a number), and False otherwise."""
IsInfinite = Annotated[_NumericType, Predicate(math.isinf)]
"""Return True if x is a positive or negative infinity, and False otherwise."""
IsNotInfinite = Annotated[_NumericType, Predicate(Not(math.isinf))]
"""Return True if x is neither a positive or negative infinity, and False otherwise."""
try:
from typing_extensions import DocInfo, doc # type: ignore [attr-defined]
except ImportError:
@dataclass(frozen=True, **SLOTS)
class DocInfo: # type: ignore [no-redef]
""" "
The return value of doc(), mainly to be used by tools that want to extract the
Annotated documentation at runtime.
"""
documentation: str
"""The documentation string passed to doc()."""
def doc(
documentation: str,
) -> DocInfo:
"""
Add documentation to a type annotation inside of Annotated.
For example:
>>> def hi(name: Annotated[int, doc("The name of the user")]) -> None: ...
"""
return DocInfo(documentation)

View file

@ -0,0 +1,147 @@
import math
import sys
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from typing import Any, Dict, Iterable, Iterator, List, NamedTuple, Set, Tuple
if sys.version_info < (3, 9):
from typing_extensions import Annotated
else:
from typing import Annotated
import annotated_types as at
class Case(NamedTuple):
"""
A test case for `annotated_types`.
"""
annotation: Any
valid_cases: Iterable[Any]
invalid_cases: Iterable[Any]
def cases() -> Iterable[Case]:
# Gt, Ge, Lt, Le
yield Case(Annotated[int, at.Gt(4)], (5, 6, 1000), (4, 0, -1))
yield Case(Annotated[float, at.Gt(0.5)], (0.6, 0.7, 0.8, 0.9), (0.5, 0.0, -0.1))
yield Case(
Annotated[datetime, at.Gt(datetime(2000, 1, 1))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
)
yield Case(
Annotated[datetime, at.Gt(date(2000, 1, 1))],
[date(2000, 1, 2), date(2000, 1, 3)],
[date(2000, 1, 1), date(1999, 12, 31)],
)
yield Case(
Annotated[datetime, at.Gt(Decimal('1.123'))],
[Decimal('1.1231'), Decimal('123')],
[Decimal('1.123'), Decimal('0')],
)
yield Case(Annotated[int, at.Ge(4)], (4, 5, 6, 1000, 4), (0, -1))
yield Case(Annotated[float, at.Ge(0.5)], (0.5, 0.6, 0.7, 0.8, 0.9), (0.4, 0.0, -0.1))
yield Case(
Annotated[datetime, at.Ge(datetime(2000, 1, 1))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(1998, 1, 1), datetime(1999, 12, 31)],
)
yield Case(Annotated[int, at.Lt(4)], (0, -1), (4, 5, 6, 1000, 4))
yield Case(Annotated[float, at.Lt(0.5)], (0.4, 0.0, -0.1), (0.5, 0.6, 0.7, 0.8, 0.9))
yield Case(
Annotated[datetime, at.Lt(datetime(2000, 1, 1))],
[datetime(1999, 12, 31), datetime(1999, 12, 31)],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
)
yield Case(Annotated[int, at.Le(4)], (4, 0, -1), (5, 6, 1000))
yield Case(Annotated[float, at.Le(0.5)], (0.5, 0.0, -0.1), (0.6, 0.7, 0.8, 0.9))
yield Case(
Annotated[datetime, at.Le(datetime(2000, 1, 1))],
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
)
# Interval
yield Case(Annotated[int, at.Interval(gt=4)], (5, 6, 1000), (4, 0, -1))
yield Case(Annotated[int, at.Interval(gt=4, lt=10)], (5, 6), (4, 10, 1000, 0, -1))
yield Case(Annotated[float, at.Interval(ge=0.5, le=1)], (0.5, 0.9, 1), (0.49, 1.1))
yield Case(
Annotated[datetime, at.Interval(gt=datetime(2000, 1, 1), le=datetime(2000, 1, 3))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(2000, 1, 1), datetime(2000, 1, 4)],
)
yield Case(Annotated[int, at.MultipleOf(multiple_of=3)], (0, 3, 9), (1, 2, 4))
yield Case(Annotated[float, at.MultipleOf(multiple_of=0.5)], (0, 0.5, 1, 1.5), (0.4, 1.1))
# lengths
yield Case(Annotated[str, at.MinLen(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
yield Case(Annotated[str, at.Len(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
yield Case(Annotated[List[int], at.MinLen(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
yield Case(Annotated[List[int], at.Len(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
yield Case(Annotated[str, at.MaxLen(4)], ('', '1234'), ('12345', 'x' * 10))
yield Case(Annotated[str, at.Len(0, 4)], ('', '1234'), ('12345', 'x' * 10))
yield Case(Annotated[List[str], at.MaxLen(4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
yield Case(Annotated[List[str], at.Len(0, 4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
yield Case(Annotated[str, at.Len(3, 5)], ('123', '12345'), ('', '1', '12', '123456', 'x' * 10))
yield Case(Annotated[str, at.Len(3, 3)], ('123',), ('12', '1234'))
yield Case(Annotated[Dict[int, int], at.Len(2, 3)], [{1: 1, 2: 2}], [{}, {1: 1}, {1: 1, 2: 2, 3: 3, 4: 4}])
yield Case(Annotated[Set[int], at.Len(2, 3)], ({1, 2}, {1, 2, 3}), (set(), {1}, {1, 2, 3, 4}))
yield Case(Annotated[Tuple[int, ...], at.Len(2, 3)], ((1, 2), (1, 2, 3)), ((), (1,), (1, 2, 3, 4)))
# Timezone
yield Case(
Annotated[datetime, at.Timezone(None)], [datetime(2000, 1, 1)], [datetime(2000, 1, 1, tzinfo=timezone.utc)]
)
yield Case(
Annotated[datetime, at.Timezone(...)], [datetime(2000, 1, 1, tzinfo=timezone.utc)], [datetime(2000, 1, 1)]
)
yield Case(
Annotated[datetime, at.Timezone(timezone.utc)],
[datetime(2000, 1, 1, tzinfo=timezone.utc)],
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
)
yield Case(
Annotated[datetime, at.Timezone('Europe/London')],
[datetime(2000, 1, 1, tzinfo=timezone(timedelta(0), name='Europe/London'))],
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
)
# predicate types
yield Case(at.LowerCase[str], ['abc', 'foobar'], ['', 'A', 'Boom'])
yield Case(at.UpperCase[str], ['ABC', 'DEFO'], ['', 'a', 'abc', 'AbC'])
yield Case(at.IsDigits[str], ['123'], ['', 'ab', 'a1b2'])
yield Case(at.IsAscii[str], ['123', 'foo bar'], ['£100', '😊', 'whatever 👀'])
yield Case(Annotated[int, at.Predicate(lambda x: x % 2 == 0)], [0, 2, 4], [1, 3, 5])
yield Case(at.IsFinite[float], [1.23], [math.nan, math.inf, -math.inf])
yield Case(at.IsNotFinite[float], [math.nan, math.inf], [1.23])
yield Case(at.IsNan[float], [math.nan], [1.23, math.inf])
yield Case(at.IsNotNan[float], [1.23, math.inf], [math.nan])
yield Case(at.IsInfinite[float], [math.inf], [math.nan, 1.23])
yield Case(at.IsNotInfinite[float], [math.nan, 1.23], [math.inf])
# check stacked predicates
yield Case(at.IsInfinite[Annotated[float, at.Predicate(lambda x: x > 0)]], [math.inf], [-math.inf, 1.23, math.nan])
# doc
yield Case(Annotated[int, at.doc("A number")], [1, 2], [])
# custom GroupedMetadata
class MyCustomGroupedMetadata(at.GroupedMetadata):
def __iter__(self) -> Iterator[at.Predicate]:
yield at.Predicate(lambda x: float(x).is_integer())
yield Case(Annotated[float, MyCustomGroupedMetadata()], [0, 2.0], [0.01, 1.5])

View file

@ -1 +1 @@
__version__ = "1.2.3" __version__ = "1.3.0"

View file

@ -168,9 +168,9 @@ class Arrow:
isinstance(tzinfo, dt_tzinfo) isinstance(tzinfo, dt_tzinfo)
and hasattr(tzinfo, "localize") and hasattr(tzinfo, "localize")
and hasattr(tzinfo, "zone") and hasattr(tzinfo, "zone")
and tzinfo.zone # type: ignore[attr-defined] and tzinfo.zone
): ):
tzinfo = parser.TzinfoParser.parse(tzinfo.zone) # type: ignore[attr-defined] tzinfo = parser.TzinfoParser.parse(tzinfo.zone)
elif isinstance(tzinfo, str): elif isinstance(tzinfo, str):
tzinfo = parser.TzinfoParser.parse(tzinfo) tzinfo = parser.TzinfoParser.parse(tzinfo)
@ -495,7 +495,7 @@ class Arrow:
yield current yield current
values = [getattr(current, f) for f in cls._ATTRS] values = [getattr(current, f) for f in cls._ATTRS]
current = cls(*values, tzinfo=tzinfo).shift( # type: ignore current = cls(*values, tzinfo=tzinfo).shift( # type: ignore[misc]
**{frame_relative: relative_steps} **{frame_relative: relative_steps}
) )
@ -578,7 +578,7 @@ class Arrow:
for _ in range(3 - len(values)): for _ in range(3 - len(values)):
values.append(1) values.append(1)
floor = self.__class__(*values, tzinfo=self.tzinfo) # type: ignore floor = self.__class__(*values, tzinfo=self.tzinfo) # type: ignore[misc]
if frame_absolute == "week": if frame_absolute == "week":
# if week_start is greater than self.isoweekday() go back one week by setting delta = 7 # if week_start is greater than self.isoweekday() go back one week by setting delta = 7
@ -792,7 +792,6 @@ class Arrow:
return self._datetime.isoformat() return self._datetime.isoformat()
def __format__(self, formatstr: str) -> str: def __format__(self, formatstr: str) -> str:
if len(formatstr) > 0: if len(formatstr) > 0:
return self.format(formatstr) return self.format(formatstr)
@ -804,7 +803,6 @@ class Arrow:
# attributes and properties # attributes and properties
def __getattr__(self, name: str) -> int: def __getattr__(self, name: str) -> int:
if name == "week": if name == "week":
return self.isocalendar()[1] return self.isocalendar()[1]
@ -965,7 +963,6 @@ class Arrow:
absolute_kwargs = {} absolute_kwargs = {}
for key, value in kwargs.items(): for key, value in kwargs.items():
if key in self._ATTRS: if key in self._ATTRS:
absolute_kwargs[key] = value absolute_kwargs[key] = value
elif key in ["week", "quarter"]: elif key in ["week", "quarter"]:
@ -1022,7 +1019,6 @@ class Arrow:
additional_attrs = ["weeks", "quarters", "weekday"] additional_attrs = ["weeks", "quarters", "weekday"]
for key, value in kwargs.items(): for key, value in kwargs.items():
if key in self._ATTRS_PLURAL or key in additional_attrs: if key in self._ATTRS_PLURAL or key in additional_attrs:
relative_kwargs[key] = value relative_kwargs[key] = value
else: else:
@ -1259,11 +1255,10 @@ class Arrow:
) )
if trunc(abs(delta)) != 1: if trunc(abs(delta)) != 1:
granularity += "s" # type: ignore granularity += "s" # type: ignore[assignment]
return locale.describe(granularity, delta, only_distance=only_distance) return locale.describe(granularity, delta, only_distance=only_distance)
else: else:
if not granularity: if not granularity:
raise ValueError( raise ValueError(
"Empty granularity list provided. " "Empty granularity list provided. "
@ -1314,7 +1309,7 @@ class Arrow:
def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow":
"""Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, that represents """Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, that represents
the time difference relative to the attrbiutes of the the time difference relative to the attributes of the
:class:`Arrow <arrow.arrow.Arrow>` object. :class:`Arrow <arrow.arrow.Arrow>` object.
:param timestring: a ``str`` representing a humanized relative time. :param timestring: a ``str`` representing a humanized relative time.
@ -1367,7 +1362,6 @@ class Arrow:
# Search input string for each time unit within locale # Search input string for each time unit within locale
for unit, unit_object in locale_obj.timeframes.items(): for unit, unit_object in locale_obj.timeframes.items():
# Need to check the type of unit_object to create the correct dictionary # Need to check the type of unit_object to create the correct dictionary
if isinstance(unit_object, Mapping): if isinstance(unit_object, Mapping):
strings_to_search = unit_object strings_to_search = unit_object
@ -1378,7 +1372,6 @@ class Arrow:
# Needs to cycle all through strings as some locales have strings that # Needs to cycle all through strings as some locales have strings that
# could overlap in a regex match, since input validation isn't being performed. # could overlap in a regex match, since input validation isn't being performed.
for time_delta, time_string in strings_to_search.items(): for time_delta, time_string in strings_to_search.items():
# Replace {0} with regex \d representing digits # Replace {0} with regex \d representing digits
search_string = str(time_string) search_string = str(time_string)
search_string = search_string.format(r"\d+") search_string = search_string.format(r"\d+")
@ -1419,7 +1412,7 @@ class Arrow:
# Assert error if string does not modify any units # Assert error if string does not modify any units
if not any([True for k, v in unit_visited.items() if v]): if not any([True for k, v in unit_visited.items() if v]):
raise ValueError( raise ValueError(
"Input string not valid. Note: Some locales do not support the week granulairty in Arrow. " "Input string not valid. Note: Some locales do not support the week granularity in Arrow. "
"If you are attempting to use the week granularity on an unsupported locale, this could be the cause of this error." "If you are attempting to use the week granularity on an unsupported locale, this could be the cause of this error."
) )
@ -1718,7 +1711,6 @@ class Arrow:
# math # math
def __add__(self, other: Any) -> "Arrow": def __add__(self, other: Any) -> "Arrow":
if isinstance(other, (timedelta, relativedelta)): if isinstance(other, (timedelta, relativedelta)):
return self.fromdatetime(self._datetime + other, self._datetime.tzinfo) return self.fromdatetime(self._datetime + other, self._datetime.tzinfo)
@ -1736,7 +1728,6 @@ class Arrow:
pass # pragma: no cover pass # pragma: no cover
def __sub__(self, other: Any) -> Union[timedelta, "Arrow"]: def __sub__(self, other: Any) -> Union[timedelta, "Arrow"]:
if isinstance(other, (timedelta, relativedelta)): if isinstance(other, (timedelta, relativedelta)):
return self.fromdatetime(self._datetime - other, self._datetime.tzinfo) return self.fromdatetime(self._datetime - other, self._datetime.tzinfo)
@ -1749,7 +1740,6 @@ class Arrow:
return NotImplemented return NotImplemented
def __rsub__(self, other: Any) -> timedelta: def __rsub__(self, other: Any) -> timedelta:
if isinstance(other, dt_datetime): if isinstance(other, dt_datetime):
return other - self._datetime return other - self._datetime
@ -1758,42 +1748,36 @@ class Arrow:
# comparisons # comparisons
def __eq__(self, other: Any) -> bool: def __eq__(self, other: Any) -> bool:
if not isinstance(other, (Arrow, dt_datetime)): if not isinstance(other, (Arrow, dt_datetime)):
return False return False
return self._datetime == self._get_datetime(other) return self._datetime == self._get_datetime(other)
def __ne__(self, other: Any) -> bool: def __ne__(self, other: Any) -> bool:
if not isinstance(other, (Arrow, dt_datetime)): if not isinstance(other, (Arrow, dt_datetime)):
return True return True
return not self.__eq__(other) return not self.__eq__(other)
def __gt__(self, other: Any) -> bool: def __gt__(self, other: Any) -> bool:
if not isinstance(other, (Arrow, dt_datetime)): if not isinstance(other, (Arrow, dt_datetime)):
return NotImplemented return NotImplemented
return self._datetime > self._get_datetime(other) return self._datetime > self._get_datetime(other)
def __ge__(self, other: Any) -> bool: def __ge__(self, other: Any) -> bool:
if not isinstance(other, (Arrow, dt_datetime)): if not isinstance(other, (Arrow, dt_datetime)):
return NotImplemented return NotImplemented
return self._datetime >= self._get_datetime(other) return self._datetime >= self._get_datetime(other)
def __lt__(self, other: Any) -> bool: def __lt__(self, other: Any) -> bool:
if not isinstance(other, (Arrow, dt_datetime)): if not isinstance(other, (Arrow, dt_datetime)):
return NotImplemented return NotImplemented
return self._datetime < self._get_datetime(other) return self._datetime < self._get_datetime(other)
def __le__(self, other: Any) -> bool: def __le__(self, other: Any) -> bool:
if not isinstance(other, (Arrow, dt_datetime)): if not isinstance(other, (Arrow, dt_datetime)):
return NotImplemented return NotImplemented
@ -1865,7 +1849,6 @@ class Arrow:
def _get_iteration_params(cls, end: Any, limit: Optional[int]) -> Tuple[Any, int]: def _get_iteration_params(cls, end: Any, limit: Optional[int]) -> Tuple[Any, int]:
"""Sets default end and limit values for range method.""" """Sets default end and limit values for range method."""
if end is None: if end is None:
if limit is None: if limit is None:
raise ValueError("One of 'end' or 'limit' is required.") raise ValueError("One of 'end' or 'limit' is required.")

View file

@ -267,11 +267,9 @@ class ArrowFactory:
raise TypeError(f"Cannot parse single argument of type {type(arg)!r}.") raise TypeError(f"Cannot parse single argument of type {type(arg)!r}.")
elif arg_count == 2: elif arg_count == 2:
arg_1, arg_2 = args[0], args[1] arg_1, arg_2 = args[0], args[1]
if isinstance(arg_1, datetime): if isinstance(arg_1, datetime):
# (datetime, tzinfo/str) -> fromdatetime @ tzinfo # (datetime, tzinfo/str) -> fromdatetime @ tzinfo
if isinstance(arg_2, (dt_tzinfo, str)): if isinstance(arg_2, (dt_tzinfo, str)):
return self.type.fromdatetime(arg_1, tzinfo=arg_2) return self.type.fromdatetime(arg_1, tzinfo=arg_2)
@ -281,7 +279,6 @@ class ArrowFactory:
) )
elif isinstance(arg_1, date): elif isinstance(arg_1, date):
# (date, tzinfo/str) -> fromdate @ tzinfo # (date, tzinfo/str) -> fromdate @ tzinfo
if isinstance(arg_2, (dt_tzinfo, str)): if isinstance(arg_2, (dt_tzinfo, str)):
return self.type.fromdate(arg_1, tzinfo=arg_2) return self.type.fromdate(arg_1, tzinfo=arg_2)

View file

@ -29,7 +29,6 @@ FORMAT_W3C: Final[str] = "YYYY-MM-DD HH:mm:ssZZ"
class DateTimeFormatter: class DateTimeFormatter:
# This pattern matches characters enclosed in square brackets are matched as # This pattern matches characters enclosed in square brackets are matched as
# an atomic group. For more info on atomic groups and how to they are # an atomic group. For more info on atomic groups and how to they are
# emulated in Python's re library, see https://stackoverflow.com/a/13577411/2701578 # emulated in Python's re library, see https://stackoverflow.com/a/13577411/2701578
@ -41,18 +40,15 @@ class DateTimeFormatter:
locale: locales.Locale locale: locales.Locale
def __init__(self, locale: str = DEFAULT_LOCALE) -> None: def __init__(self, locale: str = DEFAULT_LOCALE) -> None:
self.locale = locales.get_locale(locale) self.locale = locales.get_locale(locale)
def format(cls, dt: datetime, fmt: str) -> str: def format(cls, dt: datetime, fmt: str) -> str:
# FIXME: _format_token() is nullable # FIXME: _format_token() is nullable
return cls._FORMAT_RE.sub( return cls._FORMAT_RE.sub(
lambda m: cast(str, cls._format_token(dt, m.group(0))), fmt lambda m: cast(str, cls._format_token(dt, m.group(0))), fmt
) )
def _format_token(self, dt: datetime, token: Optional[str]) -> Optional[str]: def _format_token(self, dt: datetime, token: Optional[str]) -> Optional[str]:
if token and token.startswith("[") and token.endswith("]"): if token and token.startswith("[") and token.endswith("]"):
return token[1:-1] return token[1:-1]

View file

@ -129,7 +129,6 @@ class Locale:
_locale_map[locale_name.lower().replace("_", "-")] = cls _locale_map[locale_name.lower().replace("_", "-")] = cls
def __init__(self) -> None: def __init__(self) -> None:
self._month_name_to_ordinal = None self._month_name_to_ordinal = None
def describe( def describe(
@ -174,7 +173,7 @@ class Locale:
# Needed to determine the correct relative string to use # Needed to determine the correct relative string to use
timeframe_value = 0 timeframe_value = 0
for _unit_name, unit_value in timeframes: for _, unit_value in timeframes:
if trunc(unit_value) != 0: if trunc(unit_value) != 0:
timeframe_value = trunc(unit_value) timeframe_value = trunc(unit_value)
break break
@ -285,7 +284,6 @@ class Locale:
timeframe: TimeFrameLiteral, timeframe: TimeFrameLiteral,
delta: Union[float, int], delta: Union[float, int],
) -> str: ) -> str:
if timeframe == "now": if timeframe == "now":
return humanized return humanized
@ -425,7 +423,7 @@ class ItalianLocale(Locale):
"hours": "{0} ore", "hours": "{0} ore",
"day": "un giorno", "day": "un giorno",
"days": "{0} giorni", "days": "{0} giorni",
"week": "una settimana,", "week": "una settimana",
"weeks": "{0} settimane", "weeks": "{0} settimane",
"month": "un mese", "month": "un mese",
"months": "{0} mesi", "months": "{0} mesi",
@ -867,14 +865,16 @@ class FinnishLocale(Locale):
timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, Mapping[str, str]]]] = { timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, Mapping[str, str]]]] = {
"now": "juuri nyt", "now": "juuri nyt",
"second": "sekunti", "second": {"past": "sekunti", "future": "sekunnin"},
"seconds": {"past": "{0} muutama sekunti", "future": "{0} muutaman sekunnin"}, "seconds": {"past": "{0} sekuntia", "future": "{0} sekunnin"},
"minute": {"past": "minuutti", "future": "minuutin"}, "minute": {"past": "minuutti", "future": "minuutin"},
"minutes": {"past": "{0} minuuttia", "future": "{0} minuutin"}, "minutes": {"past": "{0} minuuttia", "future": "{0} minuutin"},
"hour": {"past": "tunti", "future": "tunnin"}, "hour": {"past": "tunti", "future": "tunnin"},
"hours": {"past": "{0} tuntia", "future": "{0} tunnin"}, "hours": {"past": "{0} tuntia", "future": "{0} tunnin"},
"day": "päivä", "day": {"past": "päivä", "future": "päivän"},
"days": {"past": "{0} päivää", "future": "{0} päivän"}, "days": {"past": "{0} päivää", "future": "{0} päivän"},
"week": {"past": "viikko", "future": "viikon"},
"weeks": {"past": "{0} viikkoa", "future": "{0} viikon"},
"month": {"past": "kuukausi", "future": "kuukauden"}, "month": {"past": "kuukausi", "future": "kuukauden"},
"months": {"past": "{0} kuukautta", "future": "{0} kuukauden"}, "months": {"past": "{0} kuukautta", "future": "{0} kuukauden"},
"year": {"past": "vuosi", "future": "vuoden"}, "year": {"past": "vuosi", "future": "vuoden"},
@ -1887,7 +1887,7 @@ class GermanBaseLocale(Locale):
future = "in {0}" future = "in {0}"
and_word = "und" and_word = "und"
timeframes = { timeframes: ClassVar[Dict[TimeFrameLiteral, str]] = {
"now": "gerade eben", "now": "gerade eben",
"second": "einer Sekunde", "second": "einer Sekunde",
"seconds": "{0} Sekunden", "seconds": "{0} Sekunden",
@ -1982,7 +1982,9 @@ class GermanBaseLocale(Locale):
return super().describe(timeframe, delta, only_distance) return super().describe(timeframe, delta, only_distance)
# German uses a different case without 'in' or 'ago' # German uses a different case without 'in' or 'ago'
humanized = self.timeframes_only_distance[timeframe].format(trunc(abs(delta))) humanized: str = self.timeframes_only_distance[timeframe].format(
trunc(abs(delta))
)
return humanized return humanized
@ -2547,6 +2549,8 @@ class ArabicLocale(Locale):
"hours": {"2": "ساعتين", "ten": "{0} ساعات", "higher": "{0} ساعة"}, "hours": {"2": "ساعتين", "ten": "{0} ساعات", "higher": "{0} ساعة"},
"day": "يوم", "day": "يوم",
"days": {"2": "يومين", "ten": "{0} أيام", "higher": "{0} يوم"}, "days": {"2": "يومين", "ten": "{0} أيام", "higher": "{0} يوم"},
"week": "اسبوع",
"weeks": {"2": "اسبوعين", "ten": "{0} أسابيع", "higher": "{0} اسبوع"},
"month": "شهر", "month": "شهر",
"months": {"2": "شهرين", "ten": "{0} أشهر", "higher": "{0} شهر"}, "months": {"2": "شهرين", "ten": "{0} أشهر", "higher": "{0} شهر"},
"year": "سنة", "year": "سنة",
@ -3709,6 +3713,8 @@ class HungarianLocale(Locale):
"hours": {"past": "{0} órával", "future": "{0} óra"}, "hours": {"past": "{0} órával", "future": "{0} óra"},
"day": {"past": "egy nappal", "future": "egy nap"}, "day": {"past": "egy nappal", "future": "egy nap"},
"days": {"past": "{0} nappal", "future": "{0} nap"}, "days": {"past": "{0} nappal", "future": "{0} nap"},
"week": {"past": "egy héttel", "future": "egy hét"},
"weeks": {"past": "{0} héttel", "future": "{0} hét"},
"month": {"past": "egy hónappal", "future": "egy hónap"}, "month": {"past": "egy hónappal", "future": "egy hónap"},
"months": {"past": "{0} hónappal", "future": "{0} hónap"}, "months": {"past": "{0} hónappal", "future": "{0} hónap"},
"year": {"past": "egy évvel", "future": "egy év"}, "year": {"past": "egy évvel", "future": "egy év"},
@ -3934,7 +3940,6 @@ class ThaiLocale(Locale):
class LaotianLocale(Locale): class LaotianLocale(Locale):
names = ["lo", "lo-la"] names = ["lo", "lo-la"]
past = "{0} ກ່ອນຫນ້ານີ້" past = "{0} ກ່ອນຫນ້ານີ້"
@ -4119,6 +4124,7 @@ class BengaliLocale(Locale):
return f"{n}র্থ" return f"{n}র্থ"
if n == 6: if n == 6:
return f"{n}ষ্ঠ" return f"{n}ষ্ঠ"
return ""
class RomanshLocale(Locale): class RomanshLocale(Locale):
@ -4137,6 +4143,8 @@ class RomanshLocale(Locale):
"hours": "{0} ura", "hours": "{0} ura",
"day": "in di", "day": "in di",
"days": "{0} dis", "days": "{0} dis",
"week": "in'emna",
"weeks": "{0} emnas",
"month": "in mais", "month": "in mais",
"months": "{0} mais", "months": "{0} mais",
"year": "in onn", "year": "in onn",
@ -5399,7 +5407,7 @@ class LuxembourgishLocale(Locale):
future = "an {0}" future = "an {0}"
and_word = "an" and_word = "an"
timeframes = { timeframes: ClassVar[Dict[TimeFrameLiteral, str]] = {
"now": "just elo", "now": "just elo",
"second": "enger Sekonn", "second": "enger Sekonn",
"seconds": "{0} Sekonnen", "seconds": "{0} Sekonnen",
@ -5487,7 +5495,9 @@ class LuxembourgishLocale(Locale):
return super().describe(timeframe, delta, only_distance) return super().describe(timeframe, delta, only_distance)
# Luxembourgish uses a different case without 'in' or 'ago' # Luxembourgish uses a different case without 'in' or 'ago'
humanized = self.timeframes_only_distance[timeframe].format(trunc(abs(delta))) humanized: str = self.timeframes_only_distance[timeframe].format(
trunc(abs(delta))
)
return humanized return humanized

View file

@ -159,7 +159,6 @@ class DateTimeParser:
_input_re_map: Dict[_FORMAT_TYPE, Pattern[str]] _input_re_map: Dict[_FORMAT_TYPE, Pattern[str]]
def __init__(self, locale: str = DEFAULT_LOCALE, cache_size: int = 0) -> None: def __init__(self, locale: str = DEFAULT_LOCALE, cache_size: int = 0) -> None:
self.locale = locales.get_locale(locale) self.locale = locales.get_locale(locale)
self._input_re_map = self._BASE_INPUT_RE_MAP.copy() self._input_re_map = self._BASE_INPUT_RE_MAP.copy()
self._input_re_map.update( self._input_re_map.update(
@ -196,7 +195,6 @@ class DateTimeParser:
def parse_iso( def parse_iso(
self, datetime_string: str, normalize_whitespace: bool = False self, datetime_string: str, normalize_whitespace: bool = False
) -> datetime: ) -> datetime:
if normalize_whitespace: if normalize_whitespace:
datetime_string = re.sub(r"\s+", " ", datetime_string.strip()) datetime_string = re.sub(r"\s+", " ", datetime_string.strip())
@ -236,13 +234,14 @@ class DateTimeParser:
] ]
if has_time: if has_time:
if has_space_divider: if has_space_divider:
date_string, time_string = datetime_string.split(" ", 1) date_string, time_string = datetime_string.split(" ", 1)
else: else:
date_string, time_string = datetime_string.split("T", 1) date_string, time_string = datetime_string.split("T", 1)
time_parts = re.split(r"[\+\-Z]", time_string, 1, re.IGNORECASE) time_parts = re.split(
r"[\+\-Z]", time_string, maxsplit=1, flags=re.IGNORECASE
)
time_components: Optional[Match[str]] = self._TIME_RE.match(time_parts[0]) time_components: Optional[Match[str]] = self._TIME_RE.match(time_parts[0])
@ -303,7 +302,6 @@ class DateTimeParser:
fmt: Union[List[str], str], fmt: Union[List[str], str],
normalize_whitespace: bool = False, normalize_whitespace: bool = False,
) -> datetime: ) -> datetime:
if normalize_whitespace: if normalize_whitespace:
datetime_string = re.sub(r"\s+", " ", datetime_string) datetime_string = re.sub(r"\s+", " ", datetime_string)
@ -341,12 +339,11 @@ class DateTimeParser:
f"Unable to find a match group for the specified token {token!r}." f"Unable to find a match group for the specified token {token!r}."
) )
self._parse_token(token, value, parts) # type: ignore self._parse_token(token, value, parts) # type: ignore[arg-type]
return self._build_datetime(parts) return self._build_datetime(parts)
def _generate_pattern_re(self, fmt: str) -> Tuple[List[_FORMAT_TYPE], Pattern[str]]: def _generate_pattern_re(self, fmt: str) -> Tuple[List[_FORMAT_TYPE], Pattern[str]]:
# fmt is a string of tokens like 'YYYY-MM-DD' # fmt is a string of tokens like 'YYYY-MM-DD'
# we construct a new string by replacing each # we construct a new string by replacing each
# token by its pattern: # token by its pattern:
@ -498,7 +495,6 @@ class DateTimeParser:
value: Any, value: Any,
parts: _Parts, parts: _Parts,
) -> None: ) -> None:
if token == "YYYY": if token == "YYYY":
parts["year"] = int(value) parts["year"] = int(value)
@ -508,7 +504,7 @@ class DateTimeParser:
elif token in ["MMMM", "MMM"]: elif token in ["MMMM", "MMM"]:
# FIXME: month_number() is nullable # FIXME: month_number() is nullable
parts["month"] = self.locale.month_number(value.lower()) # type: ignore parts["month"] = self.locale.month_number(value.lower()) # type: ignore[typeddict-item]
elif token in ["MM", "M"]: elif token in ["MM", "M"]:
parts["month"] = int(value) parts["month"] = int(value)
@ -588,7 +584,6 @@ class DateTimeParser:
weekdate = parts.get("weekdate") weekdate = parts.get("weekdate")
if weekdate is not None: if weekdate is not None:
year, week = int(weekdate[0]), int(weekdate[1]) year, week = int(weekdate[0]), int(weekdate[1])
if weekdate[2] is not None: if weekdate[2] is not None:
@ -712,7 +707,6 @@ class DateTimeParser:
) )
def _parse_multiformat(self, string: str, formats: Iterable[str]) -> datetime: def _parse_multiformat(self, string: str, formats: Iterable[str]) -> datetime:
_datetime: Optional[datetime] = None _datetime: Optional[datetime] = None
for fmt in formats: for fmt in formats:
@ -740,12 +734,11 @@ class DateTimeParser:
class TzinfoParser: class TzinfoParser:
_TZINFO_RE: ClassVar[Pattern[str]] = re.compile( _TZINFO_RE: ClassVar[Pattern[str]] = re.compile(
r"^([\+\-])?(\d{2})(?:\:?(\d{2}))?$" r"^(?:\(UTC)*([\+\-])?(\d{2})(?:\:?(\d{2}))?"
) )
@classmethod @classmethod
def parse(cls, tzinfo_string: str) -> dt_tzinfo: def parse(cls, tzinfo_string: str) -> dt_tzinfo:
tzinfo: Optional[dt_tzinfo] = None tzinfo: Optional[dt_tzinfo] = None
if tzinfo_string == "local": if tzinfo_string == "local":
@ -755,7 +748,6 @@ class TzinfoParser:
tzinfo = tz.tzutc() tzinfo = tz.tzutc()
else: else:
iso_match = cls._TZINFO_RE.match(tzinfo_string) iso_match = cls._TZINFO_RE.match(tzinfo_string)
if iso_match: if iso_match:

View file

@ -20,7 +20,7 @@ from functools import wraps
from inspect import signature from inspect import signature
def _launch_forever_coro(coro, args, kwargs, loop): async def _run_forever_coro(coro, args, kwargs, loop):
''' '''
This helper function launches an async main function that was tagged with This helper function launches an async main function that was tagged with
forever=True. There are two possibilities: forever=True. There are two possibilities:
@ -48,7 +48,7 @@ def _launch_forever_coro(coro, args, kwargs, loop):
# forever=True feature from autoasync at some point in the future. # forever=True feature from autoasync at some point in the future.
thing = coro(*args, **kwargs) thing = coro(*args, **kwargs)
if iscoroutine(thing): if iscoroutine(thing):
loop.create_task(thing) await thing
def autoasync(coro=None, *, loop=None, forever=False, pass_loop=False): def autoasync(coro=None, *, loop=None, forever=False, pass_loop=False):
@ -127,7 +127,9 @@ def autoasync(coro=None, *, loop=None, forever=False, pass_loop=False):
args, kwargs = bound_args.args, bound_args.kwargs args, kwargs = bound_args.args, bound_args.kwargs
if forever: if forever:
_launch_forever_coro(coro, args, kwargs, local_loop) local_loop.create_task(_run_forever_coro(
coro, args, kwargs, local_loop
))
local_loop.run_forever() local_loop.run_forever()
else: else:
return local_loop.run_until_complete(coro(*args, **kwargs)) return local_loop.run_until_complete(coro(*args, **kwargs))

View file

@ -26,6 +26,12 @@ def update_wrapper(
class _HashedSeq(list): class _HashedSeq(list):
"""This class guarantees that hash() will be called no more than once
per element. This is important because the lru_cache() will hash
the key multiple times on a cache miss.
"""
__slots__ = 'hashvalue' __slots__ = 'hashvalue'
def __init__(self, tup, hash=hash): def __init__(self, tup, hash=hash):
@ -41,45 +47,57 @@ def _make_key(
kwds, kwds,
typed, typed,
kwd_mark=(object(),), kwd_mark=(object(),),
fasttypes=set([int, str, frozenset, type(None)]), fasttypes={int, str},
sorted=sorted,
tuple=tuple, tuple=tuple,
type=type, type=type,
len=len, len=len,
): ):
'Make a cache key from optionally typed positional and keyword arguments' """Make a cache key from optionally typed positional and keyword arguments
The key is constructed in a way that is flat as possible rather than
as a nested structure that would take more memory.
If there is only a single argument and its data type is known to cache
its hash value, then that argument is returned without a wrapper. This
saves space and improves lookup speed.
"""
# All of code below relies on kwds preserving the order input by the user.
# Formerly, we sorted() the kwds before looping. The new way is *much*
# faster; however, it means that f(x=1, y=2) will now be treated as a
# distinct call from f(y=2, x=1) which will be cached separately.
key = args key = args
if kwds: if kwds:
sorted_items = sorted(kwds.items())
key += kwd_mark key += kwd_mark
for item in sorted_items: for item in kwds.items():
key += item key += item
if typed: if typed:
key += tuple(type(v) for v in args) key += tuple(type(v) for v in args)
if kwds: if kwds:
key += tuple(type(v) for k, v in sorted_items) key += tuple(type(v) for v in kwds.values())
elif len(key) == 1 and type(key[0]) in fasttypes: elif len(key) == 1 and type(key[0]) in fasttypes:
return key[0] return key[0]
return _HashedSeq(key) return _HashedSeq(key)
def lru_cache(maxsize=100, typed=False): # noqa: C901 def lru_cache(maxsize=128, typed=False):
"""Least-recently-used cache decorator. """Least-recently-used cache decorator.
If *maxsize* is set to None, the LRU features are disabled and the cache If *maxsize* is set to None, the LRU features are disabled and the cache
can grow without bound. can grow without bound.
If *typed* is True, arguments of different types will be cached separately. If *typed* is True, arguments of different types will be cached separately.
For example, f(3.0) and f(3) will be treated as distinct calls with For example, f(decimal.Decimal("3.0")) and f(3.0) will be treated as
distinct results. distinct calls with distinct results. Some types such as str and int may
be cached separately even when typed is false.
Arguments to the cached function must be hashable. Arguments to the cached function must be hashable.
View the cache statistics named tuple (hits, misses, maxsize, currsize) with View the cache statistics named tuple (hits, misses, maxsize, currsize)
f.cache_info(). Clear the cache and statistics with f.cache_clear(). with f.cache_info(). Clear the cache and statistics with f.cache_clear().
Access the underlying function with f.__wrapped__. Access the underlying function with f.__wrapped__.
See: http://en.wikipedia.org/wiki/Cache_algorithms#Least_Recently_Used See: https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)
""" """
@ -88,108 +106,138 @@ def lru_cache(maxsize=100, typed=False): # noqa: C901
# The internals of the lru_cache are encapsulated for thread safety and # The internals of the lru_cache are encapsulated for thread safety and
# to allow the implementation to change (including a possible C version). # to allow the implementation to change (including a possible C version).
if isinstance(maxsize, int):
# Negative maxsize is treated as 0
if maxsize < 0:
maxsize = 0
elif callable(maxsize) and isinstance(typed, bool):
# The user_function was passed in directly via the maxsize argument
user_function, maxsize = maxsize, 128
wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
wrapper.cache_parameters = lambda: {'maxsize': maxsize, 'typed': typed}
return update_wrapper(wrapper, user_function)
elif maxsize is not None:
raise TypeError('Expected first argument to be an integer, a callable, or None')
def decorating_function(user_function): def decorating_function(user_function):
cache = dict() wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
stats = [0, 0] # make statistics updateable non-locally wrapper.cache_parameters = lambda: {'maxsize': maxsize, 'typed': typed}
HITS, MISSES = 0, 1 # names for the stats fields
make_key = _make_key
cache_get = cache.get # bound method to lookup key or return None
_len = len # localize the global len() function
lock = RLock() # because linkedlist updates aren't threadsafe
root = [] # root of the circular doubly linked list
root[:] = [root, root, None, None] # initialize by pointing to self
nonlocal_root = [root] # make updateable non-locally
PREV, NEXT, KEY, RESULT = 0, 1, 2, 3 # names for the link fields
if maxsize == 0:
def wrapper(*args, **kwds):
# no caching, just do a statistics update after a successful call
result = user_function(*args, **kwds)
stats[MISSES] += 1
return result
elif maxsize is None:
def wrapper(*args, **kwds):
# simple caching without ordering or size limit
key = make_key(args, kwds, typed)
result = cache_get(
key, root
) # root used here as a unique not-found sentinel
if result is not root:
stats[HITS] += 1
return result
result = user_function(*args, **kwds)
cache[key] = result
stats[MISSES] += 1
return result
else:
def wrapper(*args, **kwds):
# size limited caching that tracks accesses by recency
key = make_key(args, kwds, typed) if kwds or typed else args
with lock:
link = cache_get(key)
if link is not None:
# record recent use of the key by moving it
# to the front of the list
(root,) = nonlocal_root
link_prev, link_next, key, result = link
link_prev[NEXT] = link_next
link_next[PREV] = link_prev
last = root[PREV]
last[NEXT] = root[PREV] = link
link[PREV] = last
link[NEXT] = root
stats[HITS] += 1
return result
result = user_function(*args, **kwds)
with lock:
(root,) = nonlocal_root
if key in cache:
# getting here means that this same key was added to the
# cache while the lock was released. since the link
# update is already done, we need only return the
# computed result and update the count of misses.
pass
elif _len(cache) >= maxsize:
# use the old root to store the new key and result
oldroot = root
oldroot[KEY] = key
oldroot[RESULT] = result
# empty the oldest link and make it the new root
root = nonlocal_root[0] = oldroot[NEXT]
oldkey = root[KEY]
root[KEY] = root[RESULT] = None
# now update the cache dictionary for the new links
del cache[oldkey]
cache[key] = oldroot
else:
# put result in a new link at the front of the list
last = root[PREV]
link = [last, root, key, result]
last[NEXT] = root[PREV] = cache[key] = link
stats[MISSES] += 1
return result
def cache_info():
"""Report cache statistics"""
with lock:
return _CacheInfo(stats[HITS], stats[MISSES], maxsize, len(cache))
def cache_clear():
"""Clear the cache and cache statistics"""
with lock:
cache.clear()
root = nonlocal_root[0]
root[:] = [root, root, None, None]
stats[:] = [0, 0]
wrapper.__wrapped__ = user_function
wrapper.cache_info = cache_info
wrapper.cache_clear = cache_clear
return update_wrapper(wrapper, user_function) return update_wrapper(wrapper, user_function)
return decorating_function return decorating_function
def _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo):
# Constants shared by all lru cache instances:
sentinel = object() # unique object used to signal cache misses
make_key = _make_key # build a key from the function arguments
PREV, NEXT, KEY, RESULT = 0, 1, 2, 3 # names for the link fields
cache = {}
hits = misses = 0
full = False
cache_get = cache.get # bound method to lookup a key or return None
cache_len = cache.__len__ # get cache size without calling len()
lock = RLock() # because linkedlist updates aren't threadsafe
root = [] # root of the circular doubly linked list
root[:] = [root, root, None, None] # initialize by pointing to self
if maxsize == 0:
def wrapper(*args, **kwds):
# No caching -- just a statistics update
nonlocal misses
misses += 1
result = user_function(*args, **kwds)
return result
elif maxsize is None:
def wrapper(*args, **kwds):
# Simple caching without ordering or size limit
nonlocal hits, misses
key = make_key(args, kwds, typed)
result = cache_get(key, sentinel)
if result is not sentinel:
hits += 1
return result
misses += 1
result = user_function(*args, **kwds)
cache[key] = result
return result
else:
def wrapper(*args, **kwds):
# Size limited caching that tracks accesses by recency
nonlocal root, hits, misses, full
key = make_key(args, kwds, typed)
with lock:
link = cache_get(key)
if link is not None:
# Move the link to the front of the circular queue
link_prev, link_next, _key, result = link
link_prev[NEXT] = link_next
link_next[PREV] = link_prev
last = root[PREV]
last[NEXT] = root[PREV] = link
link[PREV] = last
link[NEXT] = root
hits += 1
return result
misses += 1
result = user_function(*args, **kwds)
with lock:
if key in cache:
# Getting here means that this same key was added to the
# cache while the lock was released. Since the link
# update is already done, we need only return the
# computed result and update the count of misses.
pass
elif full:
# Use the old root to store the new key and result.
oldroot = root
oldroot[KEY] = key
oldroot[RESULT] = result
# Empty the oldest link and make it the new root.
# Keep a reference to the old key and old result to
# prevent their ref counts from going to zero during the
# update. That will prevent potentially arbitrary object
# clean-up code (i.e. __del__) from running while we're
# still adjusting the links.
root = oldroot[NEXT]
oldkey = root[KEY]
root[KEY] = root[RESULT] = None
# Now update the cache dictionary.
del cache[oldkey]
# Save the potentially reentrant cache[key] assignment
# for last, after the root and links have been put in
# a consistent state.
cache[key] = oldroot
else:
# Put result in a new link at the front of the queue.
last = root[PREV]
link = [last, root, key, result]
last[NEXT] = root[PREV] = cache[key] = link
# Use the cache_len bound method instead of the len() function
# which could potentially be wrapped in an lru_cache itself.
full = cache_len() >= maxsize
return result
def cache_info():
"""Report cache statistics"""
with lock:
return _CacheInfo(hits, misses, maxsize, cache_len())
def cache_clear():
"""Clear the cache and cache statistics"""
nonlocal hits, misses, full
with lock:
cache.clear()
root[:] = [root, root, None, None]
hits = misses = 0
full = False
wrapper.cache_info = cache_info
wrapper.cache_clear = cache_clear
return wrapper

View file

@ -11,9 +11,9 @@ from bleach.sanitizer import (
# yyyymmdd # yyyymmdd
__releasedate__ = "20230123" __releasedate__ = "20231006"
# x.y.z or x.y.z.dev0 -- semver # x.y.z or x.y.z.dev0 -- semver
__version__ = "6.0.0" __version__ = "6.1.0"
__all__ = ["clean", "linkify"] __all__ = ["clean", "linkify"]

View file

@ -395,10 +395,17 @@ class BleachHTMLTokenizer(HTMLTokenizer):
# followed by a series of characters. It's treated as a tag # followed by a series of characters. It's treated as a tag
# name that abruptly ends, but we should treat that like # name that abruptly ends, but we should treat that like
# character data # character data
yield { yield {"type": TAG_TOKEN_TYPE_CHARACTERS, "data": self.stream.get_tag()}
"type": TAG_TOKEN_TYPE_CHARACTERS, elif last_error_token["data"] in (
"data": "<" + self.currentToken["name"], "eof-in-attribute-name",
} "eof-in-attribute-value-no-quotes",
):
# Handle the case where the text being parsed ends with <
# followed by a series of characters and then space and then
# more characters. It's treated as a tag name followed by an
# attribute that abruptly ends, but we should treat that like
# character data.
yield {"type": TAG_TOKEN_TYPE_CHARACTERS, "data": self.stream.get_tag()}
else: else:
yield last_error_token yield last_error_token

View file

@ -45,8 +45,8 @@ def build_url_re(tlds=TLDS, protocols=html5lib_shim.allowed_protocols):
r"""\(* # Match any opening parentheses. r"""\(* # Match any opening parentheses.
\b(?<![@.])(?:(?:{0}):/{{0,3}}(?:(?:\w+:)?\w+@)?)? # http:// \b(?<![@.])(?:(?:{0}):/{{0,3}}(?:(?:\w+:)?\w+@)?)? # http://
([\w-]+\.)+(?:{1})(?:\:[0-9]+)?(?!\.\w)\b # xx.yy.tld(:##)? ([\w-]+\.)+(?:{1})(?:\:[0-9]+)?(?!\.\w)\b # xx.yy.tld(:##)?
(?:[/?][^\s\{{\}}\|\\\^\[\]`<>"]*)? (?:[/?][^\s\{{\}}\|\\\^`<>"]*)?
# /path/zz (excluding "unsafe" chars from RFC 1738, # /path/zz (excluding "unsafe" chars from RFC 3986,
# except for # and ~, which happen in practice) # except for # and ~, which happen in practice)
""".format( """.format(
"|".join(sorted(protocols)), "|".join(sorted(tlds)) "|".join(sorted(protocols)), "|".join(sorted(tlds))
@ -591,7 +591,7 @@ class LinkifyFilter(html5lib_shim.Filter):
in_a = False in_a = False
token_buffer = [] token_buffer = []
else: else:
token_buffer.append(token) token_buffer.extend(list(self.extract_entities(token)))
continue continue
if token["type"] in ["StartTag", "EmptyTag"]: if token["type"] in ["StartTag", "EmptyTag"]:

View file

@ -15,8 +15,8 @@ documentation: http://www.crummy.com/software/BeautifulSoup/bs4/doc/
""" """
__author__ = "Leonard Richardson (leonardr@segfault.org)" __author__ = "Leonard Richardson (leonardr@segfault.org)"
__version__ = "4.12.2" __version__ = "4.12.3"
__copyright__ = "Copyright (c) 2004-2023 Leonard Richardson" __copyright__ = "Copyright (c) 2004-2024 Leonard Richardson"
# Use of this source code is governed by the MIT license. # Use of this source code is governed by the MIT license.
__license__ = "MIT" __license__ = "MIT"

View file

@ -514,15 +514,19 @@ class DetectsXMLParsedAsHTML(object):
XML_PREFIX_B = b'<?xml' XML_PREFIX_B = b'<?xml'
@classmethod @classmethod
def warn_if_markup_looks_like_xml(cls, markup): def warn_if_markup_looks_like_xml(cls, markup, stacklevel=3):
"""Perform a check on some markup to see if it looks like XML """Perform a check on some markup to see if it looks like XML
that's not XHTML. If so, issue a warning. that's not XHTML. If so, issue a warning.
This is much less reliable than doing the check while parsing, This is much less reliable than doing the check while parsing,
but some of the tree builders can't do that. but some of the tree builders can't do that.
:param stacklevel: The stacklevel of the code calling this
function.
:return: True if the markup looks like non-XHTML XML, False :return: True if the markup looks like non-XHTML XML, False
otherwise. otherwise.
""" """
if isinstance(markup, bytes): if isinstance(markup, bytes):
prefix = cls.XML_PREFIX_B prefix = cls.XML_PREFIX_B
@ -535,15 +539,16 @@ class DetectsXMLParsedAsHTML(object):
and markup.startswith(prefix) and markup.startswith(prefix)
and not looks_like_html.search(markup[:500]) and not looks_like_html.search(markup[:500])
): ):
cls._warn() cls._warn(stacklevel=stacklevel+2)
return True return True
return False return False
@classmethod @classmethod
def _warn(cls): def _warn(cls, stacklevel=5):
"""Issue a warning about XML being parsed as HTML.""" """Issue a warning about XML being parsed as HTML."""
warnings.warn( warnings.warn(
XMLParsedAsHTMLWarning.MESSAGE, XMLParsedAsHTMLWarning XMLParsedAsHTMLWarning.MESSAGE, XMLParsedAsHTMLWarning,
stacklevel=stacklevel
) )
def _initialize_xml_detector(self): def _initialize_xml_detector(self):

View file

@ -77,7 +77,9 @@ class HTML5TreeBuilder(HTMLTreeBuilder):
# html5lib only parses HTML, so if it's given XML that's worth # html5lib only parses HTML, so if it's given XML that's worth
# noting. # noting.
DetectsXMLParsedAsHTML.warn_if_markup_looks_like_xml(markup) DetectsXMLParsedAsHTML.warn_if_markup_looks_like_xml(
markup, stacklevel=3
)
yield (markup, None, None, False) yield (markup, None, None, False)

View file

@ -378,10 +378,10 @@ class HTMLParserTreeBuilder(HTMLTreeBuilder):
parser.soup = self.soup parser.soup = self.soup
try: try:
parser.feed(markup) parser.feed(markup)
parser.close()
except AssertionError as e: except AssertionError as e:
# html.parser raises AssertionError in rare cases to # html.parser raises AssertionError in rare cases to
# indicate a fatal problem with the markup, especially # indicate a fatal problem with the markup, especially
# when there's an error in the doctype declaration. # when there's an error in the doctype declaration.
raise ParserRejectedMarkup(e) raise ParserRejectedMarkup(e)
parser.close()
parser.already_closed_empty_element = [] parser.already_closed_empty_element = []

View file

@ -179,7 +179,9 @@ class LXMLTreeBuilderForXML(TreeBuilder):
self.processing_instruction_class = ProcessingInstruction self.processing_instruction_class = ProcessingInstruction
# We're in HTML mode, so if we're given XML, that's worth # We're in HTML mode, so if we're given XML, that's worth
# noting. # noting.
DetectsXMLParsedAsHTML.warn_if_markup_looks_like_xml(markup) DetectsXMLParsedAsHTML.warn_if_markup_looks_like_xml(
markup, stacklevel=3
)
else: else:
self.processing_instruction_class = XMLProcessingInstruction self.processing_instruction_class = XMLProcessingInstruction

View file

@ -1356,7 +1356,7 @@ class Tag(PageElement):
This is the first step in the deepcopy process. This is the first step in the deepcopy process.
""" """
clone = type(self)( clone = type(self)(
None, self.builder, self.name, self.namespace, None, None, self.name, self.namespace,
self.prefix, self.attrs, is_xml=self._is_xml, self.prefix, self.attrs, is_xml=self._is_xml,
sourceline=self.sourceline, sourcepos=self.sourcepos, sourceline=self.sourceline, sourcepos=self.sourcepos,
can_be_empty_element=self.can_be_empty_element, can_be_empty_element=self.can_be_empty_element,
@ -1845,6 +1845,11 @@ class Tag(PageElement):
return space_before + s + space_after return space_before + s + space_after
def _format_tag(self, eventual_encoding, formatter, opening): def _format_tag(self, eventual_encoding, formatter, opening):
if self.hidden:
# A hidden tag is invisible, although its contents
# are visible.
return ''
# A tag starts with the < character (see below). # A tag starts with the < character (see below).
# Then the / character, if this is a closing tag. # Then the / character, if this is a closing tag.

View file

@ -51,7 +51,7 @@ class Formatter(EntitySubstitution):
void_element_close_prefix='/', cdata_containing_tags=None, void_element_close_prefix='/', cdata_containing_tags=None,
empty_attributes_are_booleans=False, indent=1, empty_attributes_are_booleans=False, indent=1,
): ):
"""Constructor. r"""Constructor.
:param language: This should be Formatter.XML if you are formatting :param language: This should be Formatter.XML if you are formatting
XML markup and Formatter.HTML if you are formatting HTML markup. XML markup and Formatter.HTML if you are formatting HTML markup.
@ -76,7 +76,7 @@ class Formatter(EntitySubstitution):
negative, or "" will only insert newlines. Using a negative, or "" will only insert newlines. Using a
positive integer indent indents that many spaces per positive integer indent indents that many spaces per
level. If indent is a string (such as "\t"), that string level. If indent is a string (such as "\t"), that string
is used to indent each level. The default behavior to is used to indent each level. The default behavior is to
indent one space per level. indent one space per level.
""" """
self.language = language self.language = language

View file

@ -1105,7 +1105,7 @@ class XMLTreeBuilderSmokeTest(TreeBuilderSmokeTest):
doc = """<?xml version="1.0" encoding="utf-8"?> doc = """<?xml version="1.0" encoding="utf-8"?>
<Document xmlns="http://example.com/ns0" <Document xmlns="http://example.com/ns0"
xmlns:ns1="http://example.com/ns1" xmlns:ns1="http://example.com/ns1"
xmlns:ns2="http://example.com/ns2" xmlns:ns2="http://example.com/ns2">
<ns1:tag>foo</ns1:tag> <ns1:tag>foo</ns1:tag>
<ns1:tag>bar</ns1:tag> <ns1:tag>bar</ns1:tag>
<ns2:tag key="value">baz</ns2:tag> <ns2:tag key="value">baz</ns2:tag>

View file

@ -0,0 +1 @@
˙ ><applet></applet><applet></applet><apple|><applet><applet><appl„><applet><applet></applet></applet></applet></applet><applet></applet><apple>t<applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet>et><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><azplet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><plet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet><applet></applet></applet></applet></applet></appt></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet></applet><<meta charset=utf-8>

View file

@ -0,0 +1 @@
- ˙˙ <math><select><mi><select><select>t

View file

@ -14,30 +14,75 @@ from bs4 import (
BeautifulSoup, BeautifulSoup,
ParserRejectedMarkup, ParserRejectedMarkup,
) )
try:
from soupsieve.util import SelectorSyntaxError
import lxml
import html5lib
fully_fuzzable = True
except ImportError:
fully_fuzzable = False
@pytest.mark.skipif(not fully_fuzzable, reason="Prerequisites for fuzz tests are not installed.")
class TestFuzz(object): class TestFuzz(object):
# Test case markup files from fuzzers are given this extension so # Test case markup files from fuzzers are given this extension so
# they can be included in builds. # they can be included in builds.
TESTCASE_SUFFIX = ".testcase" TESTCASE_SUFFIX = ".testcase"
# Copied 20230512 from
# https://github.com/google/oss-fuzz/blob/4ac6a645a197a695fe76532251feb5067076b3f3/projects/bs4/bs4_fuzzer.py
#
# Copying the code lets us precisely duplicate the behavior of
# oss-fuzz. The downside is that this code changes over time, so
# multiple copies of the code must be kept around to run against
# older tests. I'm not sure what to do about this, but I may
# retire old tests after a time.
def fuzz_test_with_css(self, filename):
data = self.__markup(filename)
parsers = ['lxml-xml', 'html5lib', 'html.parser', 'lxml']
try:
idx = int(data[0]) % len(parsers)
except ValueError:
return
css_selector, data = data[1:10], data[10:]
try:
soup = BeautifulSoup(data[1:], features=parsers[idx])
except ParserRejectedMarkup:
return
except ValueError:
return
list(soup.find_all(True))
try:
soup.css.select(css_selector.decode('utf-8', 'replace'))
except SelectorSyntaxError:
return
soup.prettify()
# This class of error has been fixed by catching a less helpful # This class of error has been fixed by catching a less helpful
# exception from html.parser and raising ParserRejectedMarkup # exception from html.parser and raising ParserRejectedMarkup
# instead. # instead.
@pytest.mark.parametrize( @pytest.mark.parametrize(
"filename", [ "filename", [
"clusterfuzz-testcase-minimized-bs4_fuzzer-5703933063462912", "clusterfuzz-testcase-minimized-bs4_fuzzer-5703933063462912",
"crash-ffbdfa8a2b26f13537b68d3794b0478a4090ee4a",
] ]
) )
def test_rejected_markup(self, filename): def test_rejected_markup(self, filename):
markup = self.__markup(filename) markup = self.__markup(filename)
with pytest.raises(ParserRejectedMarkup): with pytest.raises(ParserRejectedMarkup):
BeautifulSoup(markup, 'html.parser') BeautifulSoup(markup, 'html.parser')
# This class of error has to do with very deeply nested documents # This class of error has to do with very deeply nested documents
# which overflow the Python call stack when the tree is converted # which overflow the Python call stack when the tree is converted
# to a string. This is an issue with Beautiful Soup which was fixed # to a string. This is an issue with Beautiful Soup which was fixed
# as part of [bug=1471755]. # as part of [bug=1471755].
#
# These test cases are in the older format that doesn't specify
# which parser to use or give a CSS selector.
@pytest.mark.parametrize( @pytest.mark.parametrize(
"filename", [ "filename", [
"clusterfuzz-testcase-minimized-bs4_fuzzer-5984173902397440", "clusterfuzz-testcase-minimized-bs4_fuzzer-5984173902397440",
@ -46,18 +91,44 @@ class TestFuzz(object):
"clusterfuzz-testcase-minimized-bs4_fuzzer-6450958476902400", "clusterfuzz-testcase-minimized-bs4_fuzzer-6450958476902400",
] ]
) )
def test_deeply_nested_document(self, filename): def test_deeply_nested_document_without_css(self, filename):
# Parsing the document and encoding it back to a string is # Parsing the document and encoding it back to a string is
# sufficient to demonstrate that the overflow problem has # sufficient to demonstrate that the overflow problem has
# been fixed. # been fixed.
markup = self.__markup(filename) markup = self.__markup(filename)
BeautifulSoup(markup, 'html.parser').encode() BeautifulSoup(markup, 'html.parser').encode()
# This class of error has to do with very deeply nested documents
# which overflow the Python call stack when the tree is converted
# to a string. This is an issue with Beautiful Soup which was fixed
# as part of [bug=1471755].
@pytest.mark.parametrize(
"filename", [
"clusterfuzz-testcase-minimized-bs4_fuzzer-5000587759190016",
"clusterfuzz-testcase-minimized-bs4_fuzzer-5375146639360000",
"clusterfuzz-testcase-minimized-bs4_fuzzer-5492400320282624",
]
)
def test_deeply_nested_document(self, filename):
self.fuzz_test_with_css(filename)
@pytest.mark.parametrize(
"filename", [
"clusterfuzz-testcase-minimized-bs4_fuzzer-4670634698080256",
"clusterfuzz-testcase-minimized-bs4_fuzzer-5270998950477824",
]
)
def test_soupsieve_errors(self, filename):
self.fuzz_test_with_css(filename)
# This class of error represents problems with html5lib's parser, # This class of error represents problems with html5lib's parser,
# not Beautiful Soup. I use # not Beautiful Soup. I use
# https://github.com/html5lib/html5lib-python/issues/568 to notify # https://github.com/html5lib/html5lib-python/issues/568 to notify
# the html5lib developers of these issues. # the html5lib developers of these issues.
@pytest.mark.skip("html5lib problems") #
# These test cases are in the older format that doesn't specify
# which parser to use or give a CSS selector.
@pytest.mark.skip(reason="html5lib-specific problems")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"filename", [ "filename", [
# b"""ÿ<!DOCTyPEV PUBLIC'''Ð'""" # b"""ÿ<!DOCTyPEV PUBLIC'''Ð'"""
@ -68,7 +139,7 @@ class TestFuzz(object):
# b'-<math><sElect><mi><sElect><sElect>' # b'-<math><sElect><mi><sElect><sElect>'
"clusterfuzz-testcase-minimized-bs4_fuzzer-5843991618256896", "clusterfuzz-testcase-minimized-bs4_fuzzer-5843991618256896",
# b'ñ<table><svg><html>' # b'ñ<table><svg><html>'
"clusterfuzz-testcase-minimized-bs4_fuzzer-6241471367348224", "clusterfuzz-testcase-minimized-bs4_fuzzer-6241471367348224",
@ -79,10 +150,24 @@ class TestFuzz(object):
"crash-0d306a50c8ed8bcd0785b67000fcd5dea1d33f08" "crash-0d306a50c8ed8bcd0785b67000fcd5dea1d33f08"
] ]
) )
def test_html5lib_parse_errors(self, filename): def test_html5lib_parse_errors_without_css(self, filename):
markup = self.__markup(filename) markup = self.__markup(filename)
print(BeautifulSoup(markup, 'html5lib').encode()) print(BeautifulSoup(markup, 'html5lib').encode())
# This class of error represents problems with html5lib's parser,
# not Beautiful Soup. I use
# https://github.com/html5lib/html5lib-python/issues/568 to notify
# the html5lib developers of these issues.
@pytest.mark.skip(reason="html5lib-specific problems")
@pytest.mark.parametrize(
"filename", [
# b'- \xff\xff <math>\x10<select><mi><select><select>t'
"clusterfuzz-testcase-minimized-bs4_fuzzer-6306874195312640",
]
)
def test_html5lib_parse_errors(self, filename):
self.fuzz_test_with_css(filename)
def __markup(self, filename): def __markup(self, filename):
if not filename.endswith(self.TESTCASE_SUFFIX): if not filename.endswith(self.TESTCASE_SUFFIX):
filename += self.TESTCASE_SUFFIX filename += self.TESTCASE_SUFFIX

View file

@ -219,3 +219,16 @@ class TestMultiValuedAttributes(SoupTest):
) )
assert soup.a['class'] == 'foo' assert soup.a['class'] == 'foo'
assert soup.a['id'] == ['bar'] assert soup.a['id'] == ['bar']
def test_hidden_tag_is_invisible(self):
# Setting .hidden on a tag makes it invisible in output, but
# leaves its contents visible.
#
# This is not a documented or supported feature of Beautiful
# Soup (e.g. NavigableString doesn't support .hidden even
# though it could), but some people use it and it's not
# hurting anything to verify that it keeps working.
#
soup = self.soup('<div id="1"><span id="2">a string</span></div>')
soup.span.hidden = True
assert '<div id="1">a string</div>' == str(soup.div)

View file

@ -1,4 +1,4 @@
from .core import contents, where from .core import contents, where
__all__ = ["contents", "where"] __all__ = ["contents", "where"]
__version__ = "2023.07.22" __version__ = "2024.02.02"

View file

@ -245,34 +245,6 @@ mJlglFwjz1onl14LBQaTNx47aTbrqZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK
4SVhM7JZG+Ju1zdXtg2pEto= 4SVhM7JZG+Ju1zdXtg2pEto=
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: O=SECOM Trust.net OU=Security Communication RootCA1
# Subject: O=SECOM Trust.net OU=Security Communication RootCA1
# Label: "Security Communication Root CA"
# Serial: 0
# MD5 Fingerprint: f1:bc:63:6a:54:e0:b5:27:f5:cd:e7:1a:e3:4d:6e:4a
# SHA1 Fingerprint: 36:b1:2b:49:f9:81:9e:d7:4c:9e:bc:38:0f:c6:56:8f:5d:ac:b2:f7
# SHA256 Fingerprint: e7:5e:72:ed:9f:56:0e:ec:6e:b4:80:00:73:a4:3f:c3:ad:19:19:5a:39:22:82:01:78:95:97:4a:99:02:6b:6c
-----BEGIN CERTIFICATE-----
MIIDWjCCAkKgAwIBAgIBADANBgkqhkiG9w0BAQUFADBQMQswCQYDVQQGEwJKUDEY
MBYGA1UEChMPU0VDT00gVHJ1c3QubmV0MScwJQYDVQQLEx5TZWN1cml0eSBDb21t
dW5pY2F0aW9uIFJvb3RDQTEwHhcNMDMwOTMwMDQyMDQ5WhcNMjMwOTMwMDQyMDQ5
WjBQMQswCQYDVQQGEwJKUDEYMBYGA1UEChMPU0VDT00gVHJ1c3QubmV0MScwJQYD
VQQLEx5TZWN1cml0eSBDb21tdW5pY2F0aW9uIFJvb3RDQTEwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCzs/5/022x7xZ8V6UMbXaKL0u/ZPtM7orw8yl8
9f/uKuDp6bpbZCKamm8sOiZpUQWZJtzVHGpxxpp9Hp3dfGzGjGdnSj74cbAZJ6kJ
DKaVv0uMDPpVmDvY6CKhS3E4eayXkmmziX7qIWgGmBSWh9JhNrxtJ1aeV+7AwFb9
Ms+k2Y7CI9eNqPPYJayX5HA49LY6tJ07lyZDo6G8SVlyTCMwhwFY9k6+HGhWZq/N
QV3Is00qVUarH9oe4kA92819uZKAnDfdDJZkndwi92SL32HeFZRSFaB9UslLqCHJ
xrHty8OVYNEP8Ktw+N/LTX7s1vqr2b1/VPKl6Xn62dZ2JChzAgMBAAGjPzA9MB0G
A1UdDgQWBBSgc0mZaNyFW2XjmygvV5+9M7wHSDALBgNVHQ8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAaECpqLvkT115swW1F7NgE+vG
kl3g0dNq/vu+m22/xwVtWSDEHPC32oRYAmP6SBbvT6UL90qY8j+eG61Ha2POCEfr
Uj94nK9NrvjVT8+amCoQQTlSxN3Zmw7vkwGusi7KaEIkQmywszo+zenaSMQVy+n5
Bw+SUEmK3TGXX8npN6o7WWWXlDLJs58+OmJYxUmtYg5xpTKqL8aJdkNAExNnPaJU
JRDL8Try2frbSVa7pv6nQTXD4IhhyYjH3zYQIphZ6rBK+1YWc26sTfcioU+tHXot
RSflMMFe8toTyyVCUZVHA4xsIcx0Qu1T/zOLjw9XARYvz6buyXAiFL39vmwLAw==
-----END CERTIFICATE-----
# Issuer: CN=XRamp Global Certification Authority O=XRamp Security Services Inc OU=www.xrampsecurity.com # Issuer: CN=XRamp Global Certification Authority O=XRamp Security Services Inc OU=www.xrampsecurity.com
# Subject: CN=XRamp Global Certification Authority O=XRamp Security Services Inc OU=www.xrampsecurity.com # Subject: CN=XRamp Global Certification Authority O=XRamp Security Services Inc OU=www.xrampsecurity.com
# Label: "XRamp Global CA Root" # Label: "XRamp Global CA Root"
@ -881,49 +853,6 @@ Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH
WD9f WD9f
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: CN=Autoridad de Certificacion Firmaprofesional CIF A62634068
# Subject: CN=Autoridad de Certificacion Firmaprofesional CIF A62634068
# Label: "Autoridad de Certificacion Firmaprofesional CIF A62634068"
# Serial: 6047274297262753887
# MD5 Fingerprint: 73:3a:74:7a:ec:bb:a3:96:a6:c2:e4:e2:c8:9b:c0:c3
# SHA1 Fingerprint: ae:c5:fb:3f:c8:e1:bf:c4:e5:4f:03:07:5a:9a:e8:00:b7:f7:b6:fa
# SHA256 Fingerprint: 04:04:80:28:bf:1f:28:64:d4:8f:9a:d4:d8:32:94:36:6a:82:88:56:55:3f:3b:14:30:3f:90:14:7f:5d:40:ef
-----BEGIN CERTIFICATE-----
MIIGFDCCA/ygAwIBAgIIU+w77vuySF8wDQYJKoZIhvcNAQEFBQAwUTELMAkGA1UE
BhMCRVMxQjBABgNVBAMMOUF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIEZpcm1h
cHJvZmVzaW9uYWwgQ0lGIEE2MjYzNDA2ODAeFw0wOTA1MjAwODM4MTVaFw0zMDEy
MzEwODM4MTVaMFExCzAJBgNVBAYTAkVTMUIwQAYDVQQDDDlBdXRvcmlkYWQgZGUg
Q2VydGlmaWNhY2lvbiBGaXJtYXByb2Zlc2lvbmFsIENJRiBBNjI2MzQwNjgwggIi
MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKlmuO6vj78aI14H9M2uDDUtd9
thDIAl6zQyrET2qyyhxdKJp4ERppWVevtSBC5IsP5t9bpgOSL/UR5GLXMnE42QQM
cas9UX4PB99jBVzpv5RvwSmCwLTaUbDBPLutN0pcyvFLNg4kq7/DhHf9qFD0sefG
L9ItWY16Ck6WaVICqjaY7Pz6FIMMNx/Jkjd/14Et5cS54D40/mf0PmbR0/RAz15i
NA9wBj4gGFrO93IbJWyTdBSTo3OxDqqHECNZXyAFGUftaI6SEspd/NYrspI8IM/h
X68gvqB2f3bl7BqGYTM+53u0P6APjqK5am+5hyZvQWyIplD9amML9ZMWGxmPsu2b
m8mQ9QEM3xk9Dz44I8kvjwzRAv4bVdZO0I08r0+k8/6vKtMFnXkIoctXMbScyJCy
Z/QYFpM6/EfY0XiWMR+6KwxfXZmtY4laJCB22N/9q06mIqqdXuYnin1oKaPnirja
EbsXLZmdEyRG98Xi2J+Of8ePdG1asuhy9azuJBCtLxTa/y2aRnFHvkLfuwHb9H/T
KI8xWVvTyQKmtFLKbpf7Q8UIJm+K9Lv9nyiqDdVF8xM6HdjAeI9BZzwelGSuewvF
6NkBiDkal4ZkQdU7hwxu+g/GvUgUvzlN1J5Bto+WHWOWk9mVBngxaJ43BjuAiUVh
OSPHG0SjFeUc+JIwuwIDAQABo4HvMIHsMBIGA1UdEwEB/wQIMAYBAf8CAQEwDgYD
VR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRlzeurNR4APn7VdMActHNHDhpkLzCBpgYD
VR0gBIGeMIGbMIGYBgRVHSAAMIGPMC8GCCsGAQUFBwIBFiNodHRwOi8vd3d3LmZp
cm1hcHJvZmVzaW9uYWwuY29tL2NwczBcBggrBgEFBQcCAjBQHk4AUABhAHMAZQBv
ACAAZABlACAAbABhACAAQgBvAG4AYQBuAG8AdgBhACAANAA3ACAAQgBhAHIAYwBl
AGwAbwBuAGEAIAAwADgAMAAxADcwDQYJKoZIhvcNAQEFBQADggIBABd9oPm03cXF
661LJLWhAqvdpYhKsg9VSytXjDvlMd3+xDLx51tkljYyGOylMnfX40S2wBEqgLk9
am58m9Ot/MPWo+ZkKXzR4Tgegiv/J2Wv+xYVxC5xhOW1//qkR71kMrv2JYSiJ0L1
ILDCExARzRAVukKQKtJE4ZYm6zFIEv0q2skGz3QeqUvVhyj5eTSSPi5E6PaPT481
PyWzOdxjKpBrIF/EUhJOlywqrJ2X3kjyo2bbwtKDlaZmp54lD+kLM5FlClrD2VQS
3a/DTg4fJl4N3LON7NWBcN7STyQF82xO9UxJZo3R/9ILJUFI/lGExkKvgATP0H5k
SeTy36LssUzAKh3ntLFlosS88Zj0qnAHY7S42jtM+kAiMFsRpvAFDsYCA0irhpuF
3dvd6qJ2gHN99ZwExEWN57kci57q13XRcrHedUTnQn3iV2t93Jm8PYMo6oCTjcVM
ZcFwgbg4/EMxsvYDNEeyrPsiBsse3RdHHF9mudMaotoRsaS8I8nkvof/uZS2+F0g
StRf571oe2XyFR7SOqkt6dhrJKyXWERHrVkY8SFlcN7ONGCoQPHzPKTDKCOM/icz
Q0CgFzzr6juwcqajuUpLXhZI9LK8yIySxZ2frHI2vDSANGupi5LAuBft7HZT9SQB
jLMi6Et8Vcad+qMUu2WFbm5PEn4KPJ2V
-----END CERTIFICATE-----
# Issuer: CN=Izenpe.com O=IZENPE S.A. # Issuer: CN=Izenpe.com O=IZENPE S.A.
# Subject: CN=Izenpe.com O=IZENPE S.A. # Subject: CN=Izenpe.com O=IZENPE S.A.
# Label: "Izenpe.com" # Label: "Izenpe.com"
@ -4633,3 +4562,253 @@ o7Ey7Nmj1m+UI/87tyll5gfp77YZ6ufCOB0yiJA8EytuzO+rdwY0d4RPcuSBhPm5
dDTedk+SKlOxJTnbPP/lPqYO5Wue/9vsL3SD3460s6neFE3/MaNFcyT6lSnMEpcE dDTedk+SKlOxJTnbPP/lPqYO5Wue/9vsL3SD3460s6neFE3/MaNFcyT6lSnMEpcE
oji2jbDwN/zIIX8/syQbPYtuzE2wFg2WHYMfRsCbvUOZ58SWLs5fyQ== oji2jbDwN/zIIX8/syQbPYtuzE2wFg2WHYMfRsCbvUOZ58SWLs5fyQ==
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: CN=TrustAsia Global Root CA G3 O=TrustAsia Technologies, Inc.
# Subject: CN=TrustAsia Global Root CA G3 O=TrustAsia Technologies, Inc.
# Label: "TrustAsia Global Root CA G3"
# Serial: 576386314500428537169965010905813481816650257167
# MD5 Fingerprint: 30:42:1b:b7:bb:81:75:35:e4:16:4f:53:d2:94:de:04
# SHA1 Fingerprint: 63:cf:b6:c1:27:2b:56:e4:88:8e:1c:23:9a:b6:2e:81:47:24:c3:c7
# SHA256 Fingerprint: e0:d3:22:6a:eb:11:63:c2:e4:8f:f9:be:3b:50:b4:c6:43:1b:e7:bb:1e:ac:c5:c3:6b:5d:5e:c5:09:03:9a:08
-----BEGIN CERTIFICATE-----
MIIFpTCCA42gAwIBAgIUZPYOZXdhaqs7tOqFhLuxibhxkw8wDQYJKoZIhvcNAQEM
BQAwWjELMAkGA1UEBhMCQ04xJTAjBgNVBAoMHFRydXN0QXNpYSBUZWNobm9sb2dp
ZXMsIEluYy4xJDAiBgNVBAMMG1RydXN0QXNpYSBHbG9iYWwgUm9vdCBDQSBHMzAe
Fw0yMTA1MjAwMjEwMTlaFw00NjA1MTkwMjEwMTlaMFoxCzAJBgNVBAYTAkNOMSUw
IwYDVQQKDBxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMuMSQwIgYDVQQDDBtU
cnVzdEFzaWEgR2xvYmFsIFJvb3QgQ0EgRzMwggIiMA0GCSqGSIb3DQEBAQUAA4IC
DwAwggIKAoICAQDAMYJhkuSUGwoqZdC+BqmHO1ES6nBBruL7dOoKjbmzTNyPtxNS
T1QY4SxzlZHFZjtqz6xjbYdT8PfxObegQ2OwxANdV6nnRM7EoYNl9lA+sX4WuDqK
AtCWHwDNBSHvBm3dIZwZQ0WhxeiAysKtQGIXBsaqvPPW5vxQfmZCHzyLpnl5hkA1
nyDvP+uLRx+PjsXUjrYsyUQE49RDdT/VP68czH5GX6zfZBCK70bwkPAPLfSIC7Ep
qq+FqklYqL9joDiR5rPmd2jE+SoZhLsO4fWvieylL1AgdB4SQXMeJNnKziyhWTXA
yB1GJ2Faj/lN03J5Zh6fFZAhLf3ti1ZwA0pJPn9pMRJpxx5cynoTi+jm9WAPzJMs
hH/x/Gr8m0ed262IPfN2dTPXS6TIi/n1Q1hPy8gDVI+lhXgEGvNz8teHHUGf59gX
zhqcD0r83ERoVGjiQTz+LISGNzzNPy+i2+f3VANfWdP3kXjHi3dqFuVJhZBFcnAv
kV34PmVACxmZySYgWmjBNb9Pp1Hx2BErW+Canig7CjoKH8GB5S7wprlppYiU5msT
f9FkPz2ccEblooV7WIQn3MSAPmeamseaMQ4w7OYXQJXZRe0Blqq/DPNL0WP3E1jA
uPP6Z92bfW1K/zJMtSU7/xxnD4UiWQWRkUF3gdCFTIcQcf+eQxuulXUtgQIDAQAB
o2MwYTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFEDk5PIj7zjKsK5Xf/Ih
MBY027ySMB0GA1UdDgQWBBRA5OTyI+84yrCuV3/yITAWNNu8kjAOBgNVHQ8BAf8E
BAMCAQYwDQYJKoZIhvcNAQEMBQADggIBACY7UeFNOPMyGLS0XuFlXsSUT9SnYaP4
wM8zAQLpw6o1D/GUE3d3NZ4tVlFEbuHGLige/9rsR82XRBf34EzC4Xx8MnpmyFq2
XFNFV1pF1AWZLy4jVe5jaN/TG3inEpQGAHUNcoTpLrxaatXeL1nHo+zSh2bbt1S1
JKv0Q3jbSwTEb93mPmY+KfJLaHEih6D4sTNjduMNhXJEIlU/HHzp/LgV6FL6qj6j
ITk1dImmasI5+njPtqzn59ZW/yOSLlALqbUHM/Q4X6RJpstlcHboCoWASzY9M/eV
VHUl2qzEc4Jl6VL1XP04lQJqaTDFHApXB64ipCz5xUG3uOyfT0gA+QEEVcys+TIx
xHWVBqB/0Y0n3bOppHKH/lmLmnp0Ft0WpWIp6zqW3IunaFnT63eROfjXy9mPX1on
AX1daBli2MjN9LdyR75bl87yraKZk62Uy5P2EgmVtqvXO9A/EcswFi55gORngS1d
7XB4tmBZrOFdRWOPyN9yaFvqHbgB8X7754qz41SgOAngPN5C8sLtLpvzHzW2Ntjj
gKGLzZlkD8Kqq7HK9W+eQ42EVJmzbsASZthwEPEGNTNDqJwuuhQxzhB/HIbjj9LV
+Hfsm6vxL2PZQl/gZ4FkkfGXL/xuJvYz+NO1+MRiqzFRJQJ6+N1rZdVtTTDIZbpo
FGWsJwt0ivKH
-----END CERTIFICATE-----
# Issuer: CN=TrustAsia Global Root CA G4 O=TrustAsia Technologies, Inc.
# Subject: CN=TrustAsia Global Root CA G4 O=TrustAsia Technologies, Inc.
# Label: "TrustAsia Global Root CA G4"
# Serial: 451799571007117016466790293371524403291602933463
# MD5 Fingerprint: 54:dd:b2:d7:5f:d8:3e:ed:7c:e0:0b:2e:cc:ed:eb:eb
# SHA1 Fingerprint: 57:73:a5:61:5d:80:b2:e6:ac:38:82:fc:68:07:31:ac:9f:b5:92:5a
# SHA256 Fingerprint: be:4b:56:cb:50:56:c0:13:6a:52:6d:f4:44:50:8d:aa:36:a0:b5:4f:42:e4:ac:38:f7:2a:f4:70:e4:79:65:4c
-----BEGIN CERTIFICATE-----
MIICVTCCAdygAwIBAgIUTyNkuI6XY57GU4HBdk7LKnQV1tcwCgYIKoZIzj0EAwMw
WjELMAkGA1UEBhMCQ04xJTAjBgNVBAoMHFRydXN0QXNpYSBUZWNobm9sb2dpZXMs
IEluYy4xJDAiBgNVBAMMG1RydXN0QXNpYSBHbG9iYWwgUm9vdCBDQSBHNDAeFw0y
MTA1MjAwMjEwMjJaFw00NjA1MTkwMjEwMjJaMFoxCzAJBgNVBAYTAkNOMSUwIwYD
VQQKDBxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMuMSQwIgYDVQQDDBtUcnVz
dEFzaWEgR2xvYmFsIFJvb3QgQ0EgRzQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATx
s8045CVD5d4ZCbuBeaIVXxVjAd7Cq92zphtnS4CDr5nLrBfbK5bKfFJV4hrhPVbw
LxYI+hW8m7tH5j/uqOFMjPXTNvk4XatwmkcN4oFBButJ+bAp3TPsUKV/eSm4IJij
YzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUpbtKl86zK3+kMd6Xg1mD
pm9xy94wHQYDVR0OBBYEFKW7SpfOsyt/pDHel4NZg6ZvccveMA4GA1UdDwEB/wQE
AwIBBjAKBggqhkjOPQQDAwNnADBkAjBe8usGzEkxn0AAbbd+NvBNEU/zy4k6LHiR
UKNbwMp1JvK/kF0LgoxgKJ/GcJpo5PECMFxYDlZ2z1jD1xCMuo6u47xkdUfFVZDj
/bpV6wfEU6s3qe4hsiFbYI89MvHVI5TWWA==
-----END CERTIFICATE-----
# Issuer: CN=CommScope Public Trust ECC Root-01 O=CommScope
# Subject: CN=CommScope Public Trust ECC Root-01 O=CommScope
# Label: "CommScope Public Trust ECC Root-01"
# Serial: 385011430473757362783587124273108818652468453534
# MD5 Fingerprint: 3a:40:a7:fc:03:8c:9c:38:79:2f:3a:a2:6c:b6:0a:16
# SHA1 Fingerprint: 07:86:c0:d8:dd:8e:c0:80:98:06:98:d0:58:7a:ef:de:a6:cc:a2:5d
# SHA256 Fingerprint: 11:43:7c:da:7b:b4:5e:41:36:5f:45:b3:9a:38:98:6b:0d:e0:0d:ef:34:8e:0c:7b:b0:87:36:33:80:0b:c3:8b
-----BEGIN CERTIFICATE-----
MIICHTCCAaOgAwIBAgIUQ3CCd89NXTTxyq4yLzf39H91oJ4wCgYIKoZIzj0EAwMw
TjELMAkGA1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29t
bVNjb3BlIFB1YmxpYyBUcnVzdCBFQ0MgUm9vdC0wMTAeFw0yMTA0MjgxNzM1NDNa
Fw00NjA0MjgxNzM1NDJaME4xCzAJBgNVBAYTAlVTMRIwEAYDVQQKDAlDb21tU2Nv
cGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3QgRUNDIFJvb3QtMDEw
djAQBgcqhkjOPQIBBgUrgQQAIgNiAARLNumuV16ocNfQj3Rid8NeeqrltqLxeP0C
flfdkXmcbLlSiFS8LwS+uM32ENEp7LXQoMPwiXAZu1FlxUOcw5tjnSCDPgYLpkJE
hRGnSjot6dZoL0hOUysHP029uax3OVejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYD
VR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSOB2LAUN3GGQYARnQE9/OufXVNMDAKBggq
hkjOPQQDAwNoADBlAjEAnDPfQeMjqEI2Jpc1XHvr20v4qotzVRVcrHgpD7oh2MSg
2NED3W3ROT3Ek2DS43KyAjB8xX6I01D1HiXo+k515liWpDVfG2XqYZpwI7UNo5uS
Um9poIyNStDuiw7LR47QjRE=
-----END CERTIFICATE-----
# Issuer: CN=CommScope Public Trust ECC Root-02 O=CommScope
# Subject: CN=CommScope Public Trust ECC Root-02 O=CommScope
# Label: "CommScope Public Trust ECC Root-02"
# Serial: 234015080301808452132356021271193974922492992893
# MD5 Fingerprint: 59:b0:44:d5:65:4d:b8:5c:55:19:92:02:b6:d1:94:b2
# SHA1 Fingerprint: 3c:3f:ef:57:0f:fe:65:93:86:9e:a0:fe:b0:f6:ed:8e:d1:13:c7:e5
# SHA256 Fingerprint: 2f:fb:7f:81:3b:bb:b3:c8:9a:b4:e8:16:2d:0f:16:d7:15:09:a8:30:cc:9d:73:c2:62:e5:14:08:75:d1:ad:4a
-----BEGIN CERTIFICATE-----
MIICHDCCAaOgAwIBAgIUKP2ZYEFHpgE6yhR7H+/5aAiDXX0wCgYIKoZIzj0EAwMw
TjELMAkGA1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29t
bVNjb3BlIFB1YmxpYyBUcnVzdCBFQ0MgUm9vdC0wMjAeFw0yMTA0MjgxNzQ0NTRa
Fw00NjA0MjgxNzQ0NTNaME4xCzAJBgNVBAYTAlVTMRIwEAYDVQQKDAlDb21tU2Nv
cGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3QgRUNDIFJvb3QtMDIw
djAQBgcqhkjOPQIBBgUrgQQAIgNiAAR4MIHoYx7l63FRD/cHB8o5mXxO1Q/MMDAL
j2aTPs+9xYa9+bG3tD60B8jzljHz7aRP+KNOjSkVWLjVb3/ubCK1sK9IRQq9qEmU
v4RDsNuESgMjGWdqb8FuvAY5N9GIIvejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYD
VR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTmGHX/72DehKT1RsfeSlXjMjZ59TAKBggq
hkjOPQQDAwNnADBkAjAmc0l6tqvmSfR9Uj/UQQSugEODZXW5hYA4O9Zv5JOGq4/n
ich/m35rChJVYaoR4HkCMHfoMXGsPHED1oQmHhS48zs73u1Z/GtMMH9ZzkXpc2AV
mkzw5l4lIhVtwodZ0LKOag==
-----END CERTIFICATE-----
# Issuer: CN=CommScope Public Trust RSA Root-01 O=CommScope
# Subject: CN=CommScope Public Trust RSA Root-01 O=CommScope
# Label: "CommScope Public Trust RSA Root-01"
# Serial: 354030733275608256394402989253558293562031411421
# MD5 Fingerprint: 0e:b4:15:bc:87:63:5d:5d:02:73:d4:26:38:68:73:d8
# SHA1 Fingerprint: 6d:0a:5f:f7:b4:23:06:b4:85:b3:b7:97:64:fc:ac:75:f5:33:f2:93
# SHA256 Fingerprint: 02:bd:f9:6e:2a:45:dd:9b:f1:8f:c7:e1:db:df:21:a0:37:9b:a3:c9:c2:61:03:44:cf:d8:d6:06:fe:c1:ed:81
-----BEGIN CERTIFICATE-----
MIIFbDCCA1SgAwIBAgIUPgNJgXUWdDGOTKvVxZAplsU5EN0wDQYJKoZIhvcNAQEL
BQAwTjELMAkGA1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwi
Q29tbVNjb3BlIFB1YmxpYyBUcnVzdCBSU0EgUm9vdC0wMTAeFw0yMTA0MjgxNjQ1
NTRaFw00NjA0MjgxNjQ1NTNaME4xCzAJBgNVBAYTAlVTMRIwEAYDVQQKDAlDb21t
U2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3QgUlNBIFJvb3Qt
MDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwSGWjDR1C45FtnYSk
YZYSwu3D2iM0GXb26v1VWvZVAVMP8syMl0+5UMuzAURWlv2bKOx7dAvnQmtVzslh
suitQDy6uUEKBU8bJoWPQ7VAtYXR1HHcg0Hz9kXHgKKEUJdGzqAMxGBWBB0HW0al
DrJLpA6lfO741GIDuZNqihS4cPgugkY4Iw50x2tBt9Apo52AsH53k2NC+zSDO3Oj
WiE260f6GBfZumbCk6SP/F2krfxQapWsvCQz0b2If4b19bJzKo98rwjyGpg/qYFl
P8GMicWWMJoKz/TUyDTtnS+8jTiGU+6Xn6myY5QXjQ/cZip8UlF1y5mO6D1cv547
KI2DAg+pn3LiLCuz3GaXAEDQpFSOm117RTYm1nJD68/A6g3czhLmfTifBSeolz7p
UcZsBSjBAg/pGG3svZwG1KdJ9FQFa2ww8esD1eo9anbCyxooSU1/ZOD6K9pzg4H/
kQO9lLvkuI6cMmPNn7togbGEW682v3fuHX/3SZtS7NJ3Wn2RnU3COS3kuoL4b/JO
Hg9O5j9ZpSPcPYeoKFgo0fEbNttPxP/hjFtyjMcmAyejOQoBqsCyMWCDIqFPEgkB
Ea801M/XrmLTBQe0MXXgDW1XT2mH+VepuhX2yFJtocucH+X8eKg1mp9BFM6ltM6U
CBwJrVbl2rZJmkrqYxhTnCwuwwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4G
A1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUN12mmnQywsL5x6YVEFm45P3luG0wDQYJ
KoZIhvcNAQELBQADggIBAK+nz97/4L1CjU3lIpbfaOp9TSp90K09FlxD533Ahuh6
NWPxzIHIxgvoLlI1pKZJkGNRrDSsBTtXAOnTYtPZKdVUvhwQkZyybf5Z/Xn36lbQ
nmhUQo8mUuJM3y+Xpi/SB5io82BdS5pYV4jvguX6r2yBS5KPQJqTRlnLX3gWsWc+
QgvfKNmwrZggvkN80V4aCRckjXtdlemrwWCrWxhkgPut4AZ9HcpZuPN4KWfGVh2v
trV0KnahP/t1MJ+UXjulYPPLXAziDslg+MkfFoom3ecnf+slpoq9uC02EJqxWE2a
aE9gVOX2RhOOiKy8IUISrcZKiX2bwdgt6ZYD9KJ0DLwAHb/WNyVntHKLr4W96ioD
j8z7PEQkguIBpQtZtjSNMgsSDesnwv1B10A8ckYpwIzqug/xBpMu95yo9GA+o/E4
Xo4TwbM6l4c/ksp4qRyv0LAbJh6+cOx69TOY6lz/KwsETkPdY34Op054A5U+1C0w
lREQKC6/oAI+/15Z0wUOlV9TRe9rh9VIzRamloPh37MG88EU26fsHItdkJANclHn
YfkUyq+Dj7+vsQpZXdxc1+SWrVtgHdqul7I52Qb1dgAT+GhMIbA1xNxVssnBQVoc
icCMb3SgazNNtQEo/a2tiRc7ppqEvOuM6sRxJKi6KfkIsidWNTJf6jn7MZrVGczw
-----END CERTIFICATE-----
# Issuer: CN=CommScope Public Trust RSA Root-02 O=CommScope
# Subject: CN=CommScope Public Trust RSA Root-02 O=CommScope
# Label: "CommScope Public Trust RSA Root-02"
# Serial: 480062499834624527752716769107743131258796508494
# MD5 Fingerprint: e1:29:f9:62:7b:76:e2:96:6d:f3:d4:d7:0f:ae:1f:aa
# SHA1 Fingerprint: ea:b0:e2:52:1b:89:93:4c:11:68:f2:d8:9a:ac:22:4c:a3:8a:57:ae
# SHA256 Fingerprint: ff:e9:43:d7:93:42:4b:4f:7c:44:0c:1c:3d:64:8d:53:63:f3:4b:82:dc:87:aa:7a:9f:11:8f:c5:de:e1:01:f1
-----BEGIN CERTIFICATE-----
MIIFbDCCA1SgAwIBAgIUVBa/O345lXGN0aoApYYNK496BU4wDQYJKoZIhvcNAQEL
BQAwTjELMAkGA1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwi
Q29tbVNjb3BlIFB1YmxpYyBUcnVzdCBSU0EgUm9vdC0wMjAeFw0yMTA0MjgxNzE2
NDNaFw00NjA0MjgxNzE2NDJaME4xCzAJBgNVBAYTAlVTMRIwEAYDVQQKDAlDb21t
U2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3QgUlNBIFJvb3Qt
MDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDh+g77aAASyE3VrCLE
NQE7xVTlWXZjpX/rwcRqmL0yjReA61260WI9JSMZNRTpf4mnG2I81lDnNJUDMrG0
kyI9p+Kx7eZ7Ti6Hmw0zdQreqjXnfuU2mKKuJZ6VszKWpCtYHu8//mI0SFHRtI1C
rWDaSWqVcN3SAOLMV2MCe5bdSZdbkk6V0/nLKR8YSvgBKtJjCW4k6YnS5cciTNxz
hkcAqg2Ijq6FfUrpuzNPDlJwnZXjfG2WWy09X6GDRl224yW4fKcZgBzqZUPckXk2
LHR88mcGyYnJ27/aaL8j7dxrrSiDeS/sOKUNNwFnJ5rpM9kzXzehxfCrPfp4sOcs
n/Y+n2Dg70jpkEUeBVF4GiwSLFworA2iI540jwXmojPOEXcT1A6kHkIfhs1w/tku
FT0du7jyU1fbzMZ0KZwYszZ1OC4PVKH4kh+Jlk+71O6d6Ts2QrUKOyrUZHk2EOH5
kQMreyBUzQ0ZGshBMjTRsJnhkB4BQDa1t/qp5Xd1pCKBXbCL5CcSD1SIxtuFdOa3
wNemKfrb3vOTlycEVS8KbzfFPROvCgCpLIscgSjX74Yxqa7ybrjKaixUR9gqiC6v
wQcQeKwRoi9C8DfF8rhW3Q5iLc4tVn5V8qdE9isy9COoR+jUKgF4z2rDN6ieZdIs
5fq6M8EGRPbmz6UNp2YINIos8wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4G
A1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUR9DnsSL/nSz12Vdgs7GxcJXvYXowDQYJ
KoZIhvcNAQELBQADggIBAIZpsU0v6Z9PIpNojuQhmaPORVMbc0RTAIFhzTHjCLqB
KCh6krm2qMhDnscTJk3C2OVVnJJdUNjCK9v+5qiXz1I6JMNlZFxHMaNlNRPDk7n3
+VGXu6TwYofF1gbTl4MgqX67tiHCpQ2EAOHyJxCDut0DgdXdaMNmEMjRdrSzbyme
APnCKfWxkxlSaRosTKCL4BWaMS/TiJVZbuXEs1DIFAhKm4sTg7GkcrI7djNB3Nyq
pgdvHSQSn8h2vS/ZjvQs7rfSOBAkNlEv41xdgSGn2rtO/+YHqP65DSdsu3BaVXoT
6fEqSWnHX4dXTEN5bTpl6TBcQe7rd6VzEojov32u5cSoHw2OHG1QAk8mGEPej1WF
sQs3BWDJVTkSBKEqz3EWnzZRSb9wO55nnPt7eck5HHisd5FUmrh1CoFSl+NmYWvt
PjgelmFV4ZFUjO2MJB+ByRCac5krFk5yAD9UG/iNuovnFNa2RU9g7Jauwy8CTl2d
lklyALKrdVwPaFsdZcJfMw8eD/A7hvWwTruc9+olBdytoptLFwG+Qt81IR2tq670
v64fG9PiO/yzcnMcmyiQiRM9HcEARwmWmjgb3bHPDcK0RPOWlc4yOo80nOAXx17O
rg3bhzjlP1v9mxnhMUF6cKojawHhRUzNlM47ni3niAIi9G7oyOzWPPO5std3eqx7
-----END CERTIFICATE-----
# Issuer: CN=Telekom Security TLS ECC Root 2020 O=Deutsche Telekom Security GmbH
# Subject: CN=Telekom Security TLS ECC Root 2020 O=Deutsche Telekom Security GmbH
# Label: "Telekom Security TLS ECC Root 2020"
# Serial: 72082518505882327255703894282316633856
# MD5 Fingerprint: c1:ab:fe:6a:10:2c:03:8d:bc:1c:22:32:c0:85:a7:fd
# SHA1 Fingerprint: c0:f8:96:c5:a9:3b:01:06:21:07:da:18:42:48:bc:e9:9d:88:d5:ec
# SHA256 Fingerprint: 57:8a:f4:de:d0:85:3f:4e:59:98:db:4a:ea:f9:cb:ea:8d:94:5f:60:b6:20:a3:8d:1a:3c:13:b2:bc:7b:a8:e1
-----BEGIN CERTIFICATE-----
MIICQjCCAcmgAwIBAgIQNjqWjMlcsljN0AFdxeVXADAKBggqhkjOPQQDAzBjMQsw
CQYDVQQGEwJERTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0eSBH
bWJIMSswKQYDVQQDDCJUZWxla29tIFNlY3VyaXR5IFRMUyBFQ0MgUm9vdCAyMDIw
MB4XDTIwMDgyNTA3NDgyMFoXDTQ1MDgyNTIzNTk1OVowYzELMAkGA1UEBhMCREUx
JzAlBgNVBAoMHkRldXRzY2hlIFRlbGVrb20gU2VjdXJpdHkgR21iSDErMCkGA1UE
AwwiVGVsZWtvbSBTZWN1cml0eSBUTFMgRUNDIFJvb3QgMjAyMDB2MBAGByqGSM49
AgEGBSuBBAAiA2IABM6//leov9Wq9xCazbzREaK9Z0LMkOsVGJDZos0MKiXrPk/O
tdKPD/M12kOLAoC+b1EkHQ9rK8qfwm9QMuU3ILYg/4gND21Ju9sGpIeQkpT0CdDP
f8iAC8GXs7s1J8nCG6NCMEAwHQYDVR0OBBYEFONyzG6VmUex5rNhTNHLq+O6zd6f
MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA
MGQCMHVSi7ekEE+uShCLsoRbQuHmKjYC2qBuGT8lv9pZMo7k+5Dck2TOrbRBR2Di
z6fLHgIwN0GMZt9Ba9aDAEH9L1r3ULRn0SyocddDypwnJJGDSA3PzfdUga/sf+Rn
27iQ7t0l
-----END CERTIFICATE-----
# Issuer: CN=Telekom Security TLS RSA Root 2023 O=Deutsche Telekom Security GmbH
# Subject: CN=Telekom Security TLS RSA Root 2023 O=Deutsche Telekom Security GmbH
# Label: "Telekom Security TLS RSA Root 2023"
# Serial: 44676229530606711399881795178081572759
# MD5 Fingerprint: bf:5b:eb:54:40:cd:48:71:c4:20:8d:7d:de:0a:42:f2
# SHA1 Fingerprint: 54:d3:ac:b3:bd:57:56:f6:85:9d:ce:e5:c3:21:e2:d4:ad:83:d0:93
# SHA256 Fingerprint: ef:c6:5c:ad:bb:59:ad:b6:ef:e8:4d:a2:23:11:b3:56:24:b7:1b:3b:1e:a0:da:8b:66:55:17:4e:c8:97:86:46
-----BEGIN CERTIFICATE-----
MIIFszCCA5ugAwIBAgIQIZxULej27HF3+k7ow3BXlzANBgkqhkiG9w0BAQwFADBj
MQswCQYDVQQGEwJERTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0
eSBHbWJIMSswKQYDVQQDDCJUZWxla29tIFNlY3VyaXR5IFRMUyBSU0EgUm9vdCAy
MDIzMB4XDTIzMDMyODEyMTY0NVoXDTQ4MDMyNzIzNTk1OVowYzELMAkGA1UEBhMC
REUxJzAlBgNVBAoMHkRldXRzY2hlIFRlbGVrb20gU2VjdXJpdHkgR21iSDErMCkG
A1UEAwwiVGVsZWtvbSBTZWN1cml0eSBUTFMgUlNBIFJvb3QgMjAyMzCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBAO01oYGA88tKaVvC+1GDrib94W7zgRJ9
cUD/h3VCKSHtgVIs3xLBGYSJwb3FKNXVS2xE1kzbB5ZKVXrKNoIENqil/Cf2SfHV
cp6R+SPWcHu79ZvB7JPPGeplfohwoHP89v+1VmLhc2o0mD6CuKyVU/QBoCcHcqMA
U6DksquDOFczJZSfvkgdmOGjup5czQRxUX11eKvzWarE4GC+j4NSuHUaQTXtvPM6
Y+mpFEXX5lLRbtLevOP1Czvm4MS9Q2QTps70mDdsipWol8hHD/BeEIvnHRz+sTug
BTNoBUGCwQMrAcjnj02r6LX2zWtEtefdi+zqJbQAIldNsLGyMcEWzv/9FIS3R/qy
8XDe24tsNlikfLMR0cN3f1+2JeANxdKz+bi4d9s3cXFH42AYTyS2dTd4uaNir73J
co4vzLuu2+QVUhkHM/tqty1LkCiCc/4YizWN26cEar7qwU02OxY2kTLvtkCJkUPg
8qKrBC7m8kwOFjQgrIfBLX7JZkcXFBGk8/ehJImr2BrIoVyxo/eMbcgByU/J7MT8
rFEz0ciD0cmfHdRHNCk+y7AO+oMLKFjlKdw/fKifybYKu6boRhYPluV75Gp6SG12
mAWl3G0eQh5C2hrgUve1g8Aae3g1LDj1H/1Joy7SWWO/gLCMk3PLNaaZlSJhZQNg
+y+TS/qanIA7AgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtqeX
gj10hZv3PJ+TmpV5dVKMbUcwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBS2
p5eCPXSFm/c8n5OalXl1UoxtRzANBgkqhkiG9w0BAQwFAAOCAgEAqMxhpr51nhVQ
pGv7qHBFfLp+sVr8WyP6Cnf4mHGCDG3gXkaqk/QeoMPhk9tLrbKmXauw1GLLXrtm
9S3ul0A8Yute1hTWjOKWi0FpkzXmuZlrYrShF2Y0pmtjxrlO8iLpWA1WQdH6DErw
M807u20hOq6OcrXDSvvpfeWxm4bu4uB9tPcy/SKE8YXJN3nptT+/XOR0so8RYgDd
GGah2XsjX/GO1WfoVNpbOms2b/mBsTNHM3dA+VKq3dSDz4V4mZqTuXNnQkYRIer+
CqkbGmVps4+uFrb2S1ayLfmlyOw7YqPta9BO1UAJpB+Y1zqlklkg5LB9zVtzaL1t
xKITDmcZuI1CfmwMmm6gJC3VRRvcxAIU/oVbZZfKTpBQCHpCNfnqwmbU+AGuHrS+
w6jv/naaoqYfRvaE7fzbzsQCzndILIyy7MMAo+wsVRjBfhnu4S/yrYObnqsZ38aK
L4x35bcF7DvB7L6Gs4a8wPfc5+pbrrLMtTWGS9DiP7bY+A4A7l3j941Y/8+LN+lj
X273CXE2whJdV/LItM3z7gLfEdxquVeEHVlNjM7IDiPCtyaaEBRx/pOyiriA8A4Q
ntOoUAw3gi/q4Iqd4Sw5/7W0cwDk90imc6y/st53BIe0o82bNSQ3+pCTE4FCxpgm
dTdmQRCsu/WU48IxK63nI1bMNSWSs1A=
-----END CERTIFICATE-----

View file

@ -5,6 +5,10 @@ certifi.py
This module returns the installation location of cacert.pem or its contents. This module returns the installation location of cacert.pem or its contents.
""" """
import sys import sys
import atexit
def exit_cacert_ctx() -> None:
_CACERT_CTX.__exit__(None, None, None) # type: ignore[union-attr]
if sys.version_info >= (3, 11): if sys.version_info >= (3, 11):
@ -35,6 +39,7 @@ if sys.version_info >= (3, 11):
# we will also store that at the global level as well. # we will also store that at the global level as well.
_CACERT_CTX = as_file(files("certifi").joinpath("cacert.pem")) _CACERT_CTX = as_file(files("certifi").joinpath("cacert.pem"))
_CACERT_PATH = str(_CACERT_CTX.__enter__()) _CACERT_PATH = str(_CACERT_CTX.__enter__())
atexit.register(exit_cacert_ctx)
return _CACERT_PATH return _CACERT_PATH
@ -70,6 +75,7 @@ elif sys.version_info >= (3, 7):
# we will also store that at the global level as well. # we will also store that at the global level as well.
_CACERT_CTX = get_path("certifi", "cacert.pem") _CACERT_CTX = get_path("certifi", "cacert.pem")
_CACERT_PATH = str(_CACERT_CTX.__enter__()) _CACERT_PATH = str(_CACERT_CTX.__enter__())
atexit.register(exit_cacert_ctx)
return _CACERT_PATH return _CACERT_PATH

View file

@ -0,0 +1,4 @@
from .cli import cli_detect
if __name__ == "__main__":
cli_detect()

File diff suppressed because it is too large Load diff

View file

@ -4,8 +4,13 @@ from collections import Counter
from functools import lru_cache from functools import lru_cache
from typing import Counter as TypeCounter, Dict, List, Optional, Tuple from typing import Counter as TypeCounter, Dict, List, Optional, Tuple
from .assets import FREQUENCIES from .constant import (
from .constant import KO_NAMES, LANGUAGE_SUPPORTED_COUNT, TOO_SMALL_SEQUENCE, ZH_NAMES FREQUENCIES,
KO_NAMES,
LANGUAGE_SUPPORTED_COUNT,
TOO_SMALL_SEQUENCE,
ZH_NAMES,
)
from .md import is_suspiciously_successive_range from .md import is_suspiciously_successive_range
from .models import CoherenceMatches from .models import CoherenceMatches
from .utils import ( from .utils import (

View file

@ -0,0 +1,6 @@
from .__main__ import cli_detect, query_yes_no
__all__ = (
"cli_detect",
"query_yes_no",
)

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,8 @@ from .constant import (
) )
from .utils import ( from .utils import (
is_accentuated, is_accentuated,
is_ascii, is_arabic,
is_arabic_isolated_form,
is_case_variable, is_case_variable,
is_cjk, is_cjk,
is_emoticon, is_emoticon,
@ -128,8 +129,9 @@ class TooManyAccentuatedPlugin(MessDetectorPlugin):
@property @property
def ratio(self) -> float: def ratio(self) -> float:
if self._character_count == 0 or self._character_count < 8: if self._character_count < 8:
return 0.0 return 0.0
ratio_of_accentuation: float = self._accentuated_count / self._character_count ratio_of_accentuation: float = self._accentuated_count / self._character_count
return ratio_of_accentuation if ratio_of_accentuation >= 0.35 else 0.0 return ratio_of_accentuation if ratio_of_accentuation >= 0.35 else 0.0
@ -234,16 +236,13 @@ class SuspiciousRange(MessDetectorPlugin):
@property @property
def ratio(self) -> float: def ratio(self) -> float:
if self._character_count == 0: if self._character_count <= 24:
return 0.0 return 0.0
ratio_of_suspicious_range_usage: float = ( ratio_of_suspicious_range_usage: float = (
self._suspicious_successive_range_count * 2 self._suspicious_successive_range_count * 2
) / self._character_count ) / self._character_count
if ratio_of_suspicious_range_usage < 0.1:
return 0.0
return ratio_of_suspicious_range_usage return ratio_of_suspicious_range_usage
@ -296,7 +295,11 @@ class SuperWeirdWordPlugin(MessDetectorPlugin):
self._is_current_word_bad = True self._is_current_word_bad = True
# Word/Buffer ending with an upper case accentuated letter are so rare, # Word/Buffer ending with an upper case accentuated letter are so rare,
# that we will consider them all as suspicious. Same weight as foreign_long suspicious. # that we will consider them all as suspicious. Same weight as foreign_long suspicious.
if is_accentuated(self._buffer[-1]) and self._buffer[-1].isupper(): if (
is_accentuated(self._buffer[-1])
and self._buffer[-1].isupper()
and all(_.isupper() for _ in self._buffer) is False
):
self._foreign_long_count += 1 self._foreign_long_count += 1
self._is_current_word_bad = True self._is_current_word_bad = True
if buffer_length >= 24 and self._foreign_long_watch: if buffer_length >= 24 and self._foreign_long_watch:
@ -419,7 +422,7 @@ class ArchaicUpperLowerPlugin(MessDetectorPlugin):
return return
if self._current_ascii_only is True and is_ascii(character) is False: if self._current_ascii_only is True and character.isascii() is False:
self._current_ascii_only = False self._current_ascii_only = False
if self._last_alpha_seen is not None: if self._last_alpha_seen is not None:
@ -455,6 +458,34 @@ class ArchaicUpperLowerPlugin(MessDetectorPlugin):
return self._successive_upper_lower_count_final / self._character_count return self._successive_upper_lower_count_final / self._character_count
class ArabicIsolatedFormPlugin(MessDetectorPlugin):
def __init__(self) -> None:
self._character_count: int = 0
self._isolated_form_count: int = 0
def reset(self) -> None: # pragma: no cover
self._character_count = 0
self._isolated_form_count = 0
def eligible(self, character: str) -> bool:
return is_arabic(character)
def feed(self, character: str) -> None:
self._character_count += 1
if is_arabic_isolated_form(character):
self._isolated_form_count += 1
@property
def ratio(self) -> float:
if self._character_count < 8:
return 0.0
isolated_form_usage: float = self._isolated_form_count / self._character_count
return isolated_form_usage
@lru_cache(maxsize=1024) @lru_cache(maxsize=1024)
def is_suspiciously_successive_range( def is_suspiciously_successive_range(
unicode_range_a: Optional[str], unicode_range_b: Optional[str] unicode_range_a: Optional[str], unicode_range_b: Optional[str]
@ -522,6 +553,8 @@ def is_suspiciously_successive_range(
return False return False
if "Forms" in unicode_range_a or "Forms" in unicode_range_b: if "Forms" in unicode_range_a or "Forms" in unicode_range_b:
return False return False
if unicode_range_a == "Basic Latin" or unicode_range_b == "Basic Latin":
return False
return True return True

View file

@ -54,16 +54,19 @@ class CharsetMatch:
# Below 1% difference --> Use Coherence # Below 1% difference --> Use Coherence
if chaos_difference < 0.01 and coherence_difference > 0.02: if chaos_difference < 0.01 and coherence_difference > 0.02:
# When having a tough decision, use the result that decoded as many multi-byte as possible.
if chaos_difference == 0.0 and self.coherence == other.coherence:
return self.multi_byte_usage > other.multi_byte_usage
return self.coherence > other.coherence return self.coherence > other.coherence
elif chaos_difference < 0.01 and coherence_difference <= 0.02:
# When having a difficult decision, use the result that decoded as many multi-byte as possible.
# preserve RAM usage!
if len(self._payload) >= TOO_BIG_SEQUENCE:
return self.chaos < other.chaos
return self.multi_byte_usage > other.multi_byte_usage
return self.chaos < other.chaos return self.chaos < other.chaos
@property @property
def multi_byte_usage(self) -> float: def multi_byte_usage(self) -> float:
return 1.0 - len(str(self)) / len(self.raw) return 1.0 - (len(str(self)) / len(self.raw))
def __str__(self) -> str: def __str__(self) -> str:
# Lazy Str Loading # Lazy Str Loading

View file

@ -32,6 +32,8 @@ def is_accentuated(character: str) -> bool:
or "WITH DIAERESIS" in description or "WITH DIAERESIS" in description
or "WITH CIRCUMFLEX" in description or "WITH CIRCUMFLEX" in description
or "WITH TILDE" in description or "WITH TILDE" in description
or "WITH MACRON" in description
or "WITH RING ABOVE" in description
) )
@ -69,15 +71,6 @@ def is_latin(character: str) -> bool:
return "LATIN" in description return "LATIN" in description
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
def is_ascii(character: str) -> bool:
try:
character.encode("ascii")
except UnicodeEncodeError:
return False
return True
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION) @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
def is_punctuation(character: str) -> bool: def is_punctuation(character: str) -> bool:
character_category: str = unicodedata.category(character) character_category: str = unicodedata.category(character)
@ -105,7 +98,7 @@ def is_symbol(character: str) -> bool:
if character_range is None: if character_range is None:
return False return False
return "Forms" in character_range return "Forms" in character_range and character_category != "Lo"
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION) @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
@ -115,7 +108,7 @@ def is_emoticon(character: str) -> bool:
if character_range is None: if character_range is None:
return False return False
return "Emoticons" in character_range return "Emoticons" in character_range or "Pictographs" in character_range
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION) @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
@ -133,12 +126,6 @@ def is_case_variable(character: str) -> bool:
return character.islower() != character.isupper() return character.islower() != character.isupper()
def is_private_use_only(character: str) -> bool:
character_category: str = unicodedata.category(character)
return character_category == "Co"
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION) @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
def is_cjk(character: str) -> bool: def is_cjk(character: str) -> bool:
try: try:
@ -189,6 +176,26 @@ def is_thai(character: str) -> bool:
return "THAI" in character_name return "THAI" in character_name
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
def is_arabic(character: str) -> bool:
try:
character_name = unicodedata.name(character)
except ValueError:
return False
return "ARABIC" in character_name
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
def is_arabic_isolated_form(character: str) -> bool:
try:
character_name = unicodedata.name(character)
except ValueError:
return False
return "ARABIC" in character_name and "ISOLATED FORM" in character_name
@lru_cache(maxsize=len(UNICODE_RANGES_COMBINED)) @lru_cache(maxsize=len(UNICODE_RANGES_COMBINED))
def is_unicode_range_secondary(range_name: str) -> bool: def is_unicode_range_secondary(range_name: str) -> bool:
return any(keyword in range_name for keyword in UNICODE_SECONDARY_RANGE_KEYWORD) return any(keyword in range_name for keyword in UNICODE_SECONDARY_RANGE_KEYWORD)
@ -205,7 +212,7 @@ def is_unprintable(character: str) -> bool:
) )
def any_specified_encoding(sequence: bytes, search_zone: int = 4096) -> Optional[str]: def any_specified_encoding(sequence: bytes, search_zone: int = 8192) -> Optional[str]:
""" """
Extract using ASCII-only decoder any specified encoding in the first n-bytes. Extract using ASCII-only decoder any specified encoding in the first n-bytes.
""" """

View file

@ -2,5 +2,5 @@
Expose version Expose version
""" """
__version__ = "3.2.0" __version__ = "3.3.2"
VERSION = __version__.split(".") VERSION = __version__.split(".")

View file

@ -452,6 +452,6 @@ class WSGIErrorHandler(logging.Handler):
class LazyRfc3339UtcTime(object): class LazyRfc3339UtcTime(object):
def __str__(self): def __str__(self):
"""Return now() in RFC3339 UTC Format.""" """Return utcnow() in RFC3339 UTC Format."""
now = datetime.datetime.now() iso_formatted_now = datetime.datetime.utcnow().isoformat('T')
return now.isoformat('T') + 'Z' return f'{iso_formatted_now!s}Z'

View file

@ -622,13 +622,15 @@ def autovary(ignore=None, debug=False):
def convert_params(exception=ValueError, error=400): def convert_params(exception=ValueError, error=400):
"""Convert request params based on function annotations, with error handling. """Convert request params based on function annotations.
exception This function also processes errors that are subclasses of ``exception``.
Exception class to catch.
status :param BaseException exception: Exception class to catch.
The HTTP error code to return to the client on failure. :type exception: BaseException
:param error: The HTTP status code to return to the client on failure.
:type error: int
""" """
request = cherrypy.serving.request request = cherrypy.serving.request
types = request.handler.callable.__annotations__ types = request.handler.callable.__annotations__

View file

@ -47,7 +47,9 @@ try:
import pstats import pstats
def new_func_strip_path(func_name): def new_func_strip_path(func_name):
"""Make profiler output more readable by adding `__init__` modules' parents """Add ``__init__`` modules' parents.
This makes the profiler output more readable.
""" """
filename, line, name = func_name filename, line, name = func_name
if filename.endswith('__init__.py'): if filename.endswith('__init__.py'):

View file

@ -188,7 +188,7 @@ class Parser(configparser.ConfigParser):
def dict_from_file(self, file): def dict_from_file(self, file):
if hasattr(file, 'read'): if hasattr(file, 'read'):
self.readfp(file) self.read_file(file)
else: else:
self.read(file) self.read(file)
return self.as_dict() return self.as_dict()

View file

@ -1,19 +1,18 @@
"""Module with helpers for serving static files.""" """Module with helpers for serving static files."""
import mimetypes
import os import os
import platform import platform
import re import re
import stat import stat
import mimetypes
import urllib.parse
import unicodedata import unicodedata
import urllib.parse
from email.generator import _make_boundary as make_boundary from email.generator import _make_boundary as make_boundary
from io import UnsupportedOperation from io import UnsupportedOperation
import cherrypy import cherrypy
from cherrypy._cpcompat import ntob from cherrypy._cpcompat import ntob
from cherrypy.lib import cptools, httputil, file_generator_limited from cherrypy.lib import cptools, file_generator_limited, httputil
def _setup_mimetypes(): def _setup_mimetypes():
@ -185,7 +184,10 @@ def serve_fileobj(fileobj, content_type=None, disposition=None, name=None,
def _serve_fileobj(fileobj, content_type, content_length, debug=False): def _serve_fileobj(fileobj, content_type, content_length, debug=False):
"""Internal. Set response.body to the given file object, perhaps ranged.""" """Set ``response.body`` to the given file object, perhaps ranged.
Internal helper.
"""
response = cherrypy.serving.response response = cherrypy.serving.response
# HTTP/1.0 didn't have Range/Accept-Ranges headers, or the 206 code # HTTP/1.0 didn't have Range/Accept-Ranges headers, or the 206 code

View file

@ -494,7 +494,7 @@ class Bus(object):
"Cannot reconstruct command from '-c'. " "Cannot reconstruct command from '-c'. "
'Ref: https://github.com/cherrypy/cherrypy/issues/1545') 'Ref: https://github.com/cherrypy/cherrypy/issues/1545')
except AttributeError: except AttributeError:
"""It looks Py_GetArgcArgv is completely absent in some environments """It looks Py_GetArgcArgv's completely absent in some environments
It is known, that there's no Py_GetArgcArgv in MS Windows and It is known, that there's no Py_GetArgcArgv in MS Windows and
``ctypes`` module is completely absent in Google AppEngine ``ctypes`` module is completely absent in Google AppEngine

View file

@ -136,6 +136,9 @@ class HTTPTests(helper.CPWebCase):
self.assertStatus(200) self.assertStatus(200)
self.assertBody(b'Hello world!') self.assertBody(b'Hello world!')
response.close()
c.close()
# Now send a message that has no Content-Length, but does send a body. # Now send a message that has no Content-Length, but does send a body.
# Verify that CP times out the socket and responds # Verify that CP times out the socket and responds
# with 411 Length Required. # with 411 Length Required.
@ -159,6 +162,9 @@ class HTTPTests(helper.CPWebCase):
self.status = str(response.status) self.status = str(response.status)
self.assertStatus(411) self.assertStatus(411)
response.close()
c.close()
def test_post_multipart(self): def test_post_multipart(self):
alphabet = 'abcdefghijklmnopqrstuvwxyz' alphabet = 'abcdefghijklmnopqrstuvwxyz'
# generate file contents for a large post # generate file contents for a large post
@ -184,6 +190,9 @@ class HTTPTests(helper.CPWebCase):
parts = ['%s * 65536' % ch for ch in alphabet] parts = ['%s * 65536' % ch for ch in alphabet]
self.assertBody(', '.join(parts)) self.assertBody(', '.join(parts))
response.close()
c.close()
def test_post_filename_with_special_characters(self): def test_post_filename_with_special_characters(self):
"""Testing that we can handle filenames with special characters. """Testing that we can handle filenames with special characters.
@ -217,6 +226,9 @@ class HTTPTests(helper.CPWebCase):
self.assertStatus(200) self.assertStatus(200)
self.assertBody(fname) self.assertBody(fname)
response.close()
c.close()
def test_malformed_request_line(self): def test_malformed_request_line(self):
if getattr(cherrypy.server, 'using_apache', False): if getattr(cherrypy.server, 'using_apache', False):
return self.skip('skipped due to known Apache differences...') return self.skip('skipped due to known Apache differences...')
@ -264,6 +276,9 @@ class HTTPTests(helper.CPWebCase):
self.body = response.fp.read(20) self.body = response.fp.read(20)
self.assertBody('Illegal header line.') self.assertBody('Illegal header line.')
response.close()
c.close()
def test_http_over_https(self): def test_http_over_https(self):
if self.scheme != 'https': if self.scheme != 'https':
return self.skip('skipped (not running HTTPS)... ') return self.skip('skipped (not running HTTPS)... ')

View file

@ -150,6 +150,8 @@ class IteratorTest(helper.CPWebCase):
self.assertStatus(200) self.assertStatus(200)
self.assertBody('0') self.assertBody('0')
itr_conn.close()
# Now we do the same check with streaming - some classes will # Now we do the same check with streaming - some classes will
# be automatically closed, while others cannot. # be automatically closed, while others cannot.
stream_counts = {} stream_counts = {}

View file

@ -1,5 +1,6 @@
"""Basic tests for the CherryPy core: request handling.""" """Basic tests for the CherryPy core: request handling."""
import datetime
import logging import logging
from cheroot.test import webtest from cheroot.test import webtest
@ -197,6 +198,33 @@ def test_custom_log_format(log_tracker, monkeypatch, server):
) )
def test_utc_in_timez(monkeypatch):
"""Test that ``LazyRfc3339UtcTime`` is rendered as ``str`` using UTC timestamp."""
utcoffset8_local_time_in_naive_utc = (
datetime.datetime(
year=2020,
month=1,
day=1,
hour=1,
minute=23,
second=45,
tzinfo=datetime.timezone(datetime.timedelta(hours=8)),
)
.astimezone(datetime.timezone.utc)
.replace(tzinfo=None)
)
class mock_datetime:
@classmethod
def utcnow(cls):
return utcoffset8_local_time_in_naive_utc
monkeypatch.setattr('datetime.datetime', mock_datetime)
rfc3339_utc_time = str(cherrypy._cplogging.LazyRfc3339UtcTime())
expected_time = '2019-12-31T17:23:45Z'
assert rfc3339_utc_time == expected_time
def test_timez_log_format(log_tracker, monkeypatch, server): def test_timez_log_format(log_tracker, monkeypatch, server):
"""Test a customized access_log_format string, which is a """Test a customized access_log_format string, which is a
feature of _cplogging.LogManager.access().""" feature of _cplogging.LogManager.access()."""

View file

@ -38,7 +38,7 @@ CL_BLANK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAA
URI_SCHEME = "cloudinary" URI_SCHEME = "cloudinary"
API_VERSION = "v1_1" API_VERSION = "v1_1"
VERSION = "1.34.0" VERSION = "1.39.1"
_USER_PLATFORM_DETAILS = "; ".join((platform(), "Python {}".format(python_version()))) _USER_PLATFORM_DETAILS = "; ".join((platform(), "Python {}".format(python_version())))
@ -741,7 +741,11 @@ class CloudinaryResource(object):
:return: Video tag :return: Video tag
""" """
public_id = options.get('public_id', self.public_id) public_id = options.get('public_id', self.public_id)
source = re.sub(r"\.({0})$".format("|".join(self.default_source_types())), '', public_id) use_fetch_format = options.get('use_fetch_format', config().use_fetch_format)
if not use_fetch_format:
source = re.sub(r"\.({0})$".format("|".join(self.default_source_types())), '', public_id)
else:
source = public_id
custom_attributes = options.pop("attributes", dict()) custom_attributes = options.pop("attributes", dict())

View file

@ -14,7 +14,8 @@ from cloudinary import utils
from cloudinary.api_client.call_api import ( from cloudinary.api_client.call_api import (
call_api, call_api,
call_metadata_api, call_metadata_api,
call_json_api call_json_api,
_call_v2_api
) )
from cloudinary.exceptions import ( from cloudinary.exceptions import (
BadRequest, BadRequest,
@ -54,6 +55,19 @@ def usage(**options):
return call_api("get", uri, {}, **options) return call_api("get", uri, {}, **options)
def config(**options):
"""
Get account config details.
:param options: Additional options.
:type options: dict, optional
:return: Detailed config information.
:rtype: Response
"""
params = only(options, "settings")
return call_api("get", ["config"], params, **options)
def resource_types(**options): def resource_types(**options):
return call_api("get", ["resources"], {}, **options) return call_api("get", ["resources"], {}, **options)
@ -64,24 +78,22 @@ def resources(**options):
uri = ["resources", resource_type] uri = ["resources", resource_type]
if upload_type: if upload_type:
uri.append(upload_type) uri.append(upload_type)
params = only(options, "next_cursor", "max_results", "prefix", "tags", params = __list_resources_params(**options)
"context", "moderations", "direction", "start_at", "metadata") params.update(only(options, "prefix", "start_at"))
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
def resources_by_tag(tag, **options): def resources_by_tag(tag, **options):
resource_type = options.pop("resource_type", "image") resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "tags", tag] uri = ["resources", resource_type, "tags", tag]
params = only(options, "next_cursor", "max_results", "tags", params = __list_resources_params(**options)
"context", "moderations", "direction", "metadata")
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
def resources_by_moderation(kind, status, **options): def resources_by_moderation(kind, status, **options):
resource_type = options.pop("resource_type", "image") resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "moderations", kind, status] uri = ["resources", resource_type, "moderations", kind, status]
params = only(options, "next_cursor", "max_results", "tags", params = __list_resources_params(**options)
"context", "moderations", "direction", "metadata")
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
@ -89,7 +101,7 @@ def resources_by_ids(public_ids, **options):
resource_type = options.pop("resource_type", "image") resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", "upload") upload_type = options.pop("type", "upload")
uri = ["resources", resource_type, upload_type] uri = ["resources", resource_type, upload_type]
params = dict(only(options, "tags", "moderations", "context"), public_ids=public_ids) params = dict(__resources_params(**options), public_ids=public_ids)
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
@ -105,7 +117,7 @@ def resources_by_asset_folder(asset_folder, **options):
:rtype: Response :rtype: Response
""" """
uri = ["resources", "by_asset_folder"] uri = ["resources", "by_asset_folder"]
params = only(options, "max_results", "tags", "moderations", "context", "next_cursor") params = __list_resources_params(**options)
params["asset_folder"] = asset_folder params["asset_folder"] = asset_folder
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
@ -125,7 +137,7 @@ def resources_by_asset_ids(asset_ids, **options):
:rtype: Response :rtype: Response
""" """
uri = ["resources", 'by_asset_ids'] uri = ["resources", 'by_asset_ids']
params = dict(only(options, "tags", "moderations", "context"), asset_ids=asset_ids) params = dict(__resources_params(**options), asset_ids=asset_ids)
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
@ -147,15 +159,43 @@ def resources_by_context(key, value=None, **options):
""" """
resource_type = options.pop("resource_type", "image") resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "context"] uri = ["resources", resource_type, "context"]
params = only(options, "next_cursor", "max_results", "tags", params = __list_resources_params(**options)
"context", "moderations", "direction", "metadata")
params["key"] = key params["key"] = key
if value is not None: if value is not None:
params["value"] = value params["value"] = value
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
def visual_search(image_url=None, image_asset_id=None, text=None, **options): def __resources_params(**options):
"""
Prepares optional parameters for resources_* API calls.
:param options: Additional options
:return: Optional parameters
:internal
"""
params = only(options, "tags", "context", "metadata", "moderations")
params["fields"] = options.get("fields") and utils.encode_list(utils.build_array(options["fields"]))
return params
def __list_resources_params(**options):
"""
Prepares optional parameters for resources_* API calls.
:param options: Additional options
:return: Optional parameters
:internal
"""
resources_params = __resources_params(**options)
resources_params.update(only(options, "next_cursor", "max_results", "direction"))
return resources_params
def visual_search(image_url=None, image_asset_id=None, text=None, image_file=None, **options):
""" """
Find images based on their visual content. Find images based on their visual content.
@ -165,14 +205,17 @@ def visual_search(image_url=None, image_asset_id=None, text=None, **options):
:type image_asset_id: str :type image_asset_id: str
:param text: A textual description, e.g., "cat" :param text: A textual description, e.g., "cat"
:type text: str :type text: str
:param image_file: The image file.
:type image_file: str|callable|Path|bytes
:param options: Additional options :param options: Additional options
:type options: dict, optional :type options: dict, optional
:return: Resources (assets) that were found :return: Resources (assets) that were found
:rtype: Response :rtype: Response
""" """
uri = ["resources", "visual_search"] uri = ["resources", "visual_search"]
params = {"image_url": image_url, "image_asset_id": image_asset_id, "text": text} params = {"image_url": image_url, "image_asset_id": image_asset_id, "text": text,
return call_api("get", uri, params, **options) "image_file": utils.handle_file_parameter(image_file, "file")}
return call_api("post", uri, params, **options)
def resource(public_id, **options): def resource(public_id, **options):
@ -224,11 +267,11 @@ def update(public_id, **options):
if "tags" in options: if "tags" in options:
params["tags"] = ",".join(utils.build_array(options["tags"])) params["tags"] = ",".join(utils.build_array(options["tags"]))
if "face_coordinates" in options: if "face_coordinates" in options:
params["face_coordinates"] = utils.encode_double_array( params["face_coordinates"] = utils.encode_double_array(options.get("face_coordinates"))
options.get("face_coordinates"))
if "custom_coordinates" in options: if "custom_coordinates" in options:
params["custom_coordinates"] = utils.encode_double_array( params["custom_coordinates"] = utils.encode_double_array(options.get("custom_coordinates"))
options.get("custom_coordinates")) if "regions" in options:
params["regions"] = utils.json_encode(options.get("regions"))
if "context" in options: if "context" in options:
params["context"] = utils.encode_context(options.get("context")) params["context"] = utils.encode_context(options.get("context"))
if "metadata" in options: if "metadata" in options:
@ -656,9 +699,8 @@ def add_metadata_field(field, **options):
:rtype: Response :rtype: Response
""" """
params = only(field, "type", "external_id", "label", "mandatory",
"default_value", "validation", "datasource") return call_metadata_api("post", [], __metadata_field_params(field), **options)
return call_metadata_api("post", [], params, **options)
def update_metadata_field(field_external_id, field, **options): def update_metadata_field(field_external_id, field, **options):
@ -677,8 +719,13 @@ def update_metadata_field(field_external_id, field, **options):
:rtype: Response :rtype: Response
""" """
uri = [field_external_id] uri = [field_external_id]
params = only(field, "label", "mandatory", "default_value", "validation")
return call_metadata_api("put", uri, params, **options) return call_metadata_api("put", uri, __metadata_field_params(field), **options)
def __metadata_field_params(field):
return only(field, "type", "external_id", "label", "mandatory", "restrictions",
"default_value", "validation", "datasource")
def delete_metadata_field(field_external_id, **options): def delete_metadata_field(field_external_id, **options):
@ -798,3 +845,18 @@ def reorder_metadata_fields(order_by, direction=None, **options):
uri = ['order'] uri = ['order']
params = {'order_by': order_by, 'direction': direction} params = {'order_by': order_by, 'direction': direction}
return call_metadata_api('put', uri, params, **options) return call_metadata_api('put', uri, params, **options)
def analyze(input_type, analysis_type, uri=None, **options):
"""Analyzes an asset with the requested analysis type.
:param input_type: The type of input for the asset to analyze ('uri').
:param analysis_type: The type of analysis to run ('google_tagging', 'captioning', 'fashion').
:param uri: The URI of the asset to analyze.
:param options: Additional options.
:rtype: Response
"""
api_uri = ['analysis', 'analyze', input_type]
params = {'analysis_type': analysis_type, 'uri': uri, 'parameters': options.get("parameters")}
return _call_v2_api('post', api_uri, params, **options)

View file

@ -1,8 +1,7 @@
import cloudinary import cloudinary
from cloudinary.api_client.execute_request import execute_request from cloudinary.api_client.execute_request import execute_request
from cloudinary.provisioning.account_config import account_config from cloudinary.provisioning.account_config import account_config
from cloudinary.utils import get_http_connector from cloudinary.utils import get_http_connector, normalize_params
PROVISIONING_SUB_PATH = "provisioning" PROVISIONING_SUB_PATH = "provisioning"
ACCOUNT_SUB_PATH = "accounts" ACCOUNT_SUB_PATH = "accounts"
@ -28,7 +27,7 @@ def _call_account_api(method, uri, params=None, headers=None, **options):
return execute_request(http_connector=_http, return execute_request(http_connector=_http,
method=method, method=method,
params=params, params=normalize_params(params),
headers=headers, headers=headers,
auth=auth, auth=auth,
api_url=provisioning_api_url, api_url=provisioning_api_url,

View file

@ -2,8 +2,7 @@ import json
import cloudinary import cloudinary
from cloudinary.api_client.execute_request import execute_request from cloudinary.api_client.execute_request import execute_request
from cloudinary.utils import get_http_connector from cloudinary.utils import get_http_connector, normalize_params
logger = cloudinary.logger logger = cloudinary.logger
_http = get_http_connector(cloudinary.config(), cloudinary.CERT_KWARGS) _http = get_http_connector(cloudinary.config(), cloudinary.CERT_KWARGS)
@ -27,6 +26,10 @@ def call_json_api(method, uri, json_body, **options):
return _call_api(method, uri, body=data, headers={'Content-Type': 'application/json'}, **options) return _call_api(method, uri, body=data, headers={'Content-Type': 'application/json'}, **options)
def _call_v2_api(method, uri, json_body, **options):
return call_json_api(method, uri, json_body=json_body, api_version='v2', **options)
def call_api(method, uri, params, **options): def call_api(method, uri, params, **options):
return _call_api(method, uri, params=params, **options) return _call_api(method, uri, params=params, **options)
@ -43,10 +46,11 @@ def _call_api(method, uri, params=None, body=None, headers=None, extra_headers=N
oauth_token = options.pop("oauth_token", cloudinary.config().oauth_token) oauth_token = options.pop("oauth_token", cloudinary.config().oauth_token)
_validate_authorization(api_key, api_secret, oauth_token) _validate_authorization(api_key, api_secret, oauth_token)
api_url = "/".join([prefix, cloudinary.API_VERSION, cloud_name] + uri)
auth = {"key": api_key, "secret": api_secret, "oauth_token": oauth_token} auth = {"key": api_key, "secret": api_secret, "oauth_token": oauth_token}
api_version = options.pop("api_version", cloudinary.API_VERSION)
api_url = "/".join([prefix, api_version, cloud_name] + uri)
if body is not None: if body is not None:
options["body"] = body options["body"] = body
@ -55,7 +59,7 @@ def _call_api(method, uri, params=None, body=None, headers=None, extra_headers=N
return execute_request(http_connector=_http, return execute_request(http_connector=_http,
method=method, method=method,
params=params, params=normalize_params(params),
headers=headers, headers=headers,
auth=auth, auth=auth,
api_url=api_url, api_url=api_url,

View file

@ -63,9 +63,8 @@ def execute_request(http_connector, method, params, headers, auth, api_url, **op
processed_params = process_params(params) processed_params = process_params(params)
api_url = smart_escape(unquote(api_url)) api_url = smart_escape(unquote(api_url))
try: try:
response = http_connector.request(method.upper(), api_url, processed_params, req_headers, **kw) response = http_connector.request(method=method.upper(), url=api_url, fields=processed_params, headers=req_headers, **kw)
body = response.data body = response.data
except HTTPError as e: except HTTPError as e:
raise GeneralError("Unexpected error %s" % str(e)) raise GeneralError("Unexpected error %s" % str(e))

View file

@ -24,7 +24,7 @@ class HttpClient:
def get_json(self, url): def get_json(self, url):
try: try:
response = self._http_client.request("GET", url, timeout=self.timeout) response = self._http_client.request(method="GET", url=url, timeout=self.timeout)
body = response.data body = response.data
except HTTPError as e: except HTTPError as e:
raise GeneralError("Unexpected error %s" % str(e)) raise GeneralError("Unexpected error %s" % str(e))

View file

@ -2,4 +2,5 @@ from .account_config import AccountConfig, account_config, reset_config
from .account import (sub_accounts, create_sub_account, delete_sub_account, sub_account, update_sub_account, from .account import (sub_accounts, create_sub_account, delete_sub_account, sub_account, update_sub_account,
user_groups, create_user_group, update_user_group, delete_user_group, user_group, user_groups, create_user_group, update_user_group, delete_user_group, user_group,
add_user_to_group, remove_user_from_group, user_group_users, user_in_user_groups, add_user_to_group, remove_user_from_group, user_group_users, user_in_user_groups,
users, create_user, delete_user, user, update_user, Role) users, create_user, delete_user, user, update_user, access_keys, generate_access_key,
update_access_key, delete_access_key, Role)

View file

@ -1,10 +1,10 @@
from cloudinary.api_client.call_account_api import _call_account_api from cloudinary.api_client.call_account_api import _call_account_api
from cloudinary.utils import encode_list from cloudinary.utils import encode_list
SUB_ACCOUNTS_SUB_PATH = "sub_accounts" SUB_ACCOUNTS_SUB_PATH = "sub_accounts"
USERS_SUB_PATH = "users" USERS_SUB_PATH = "users"
USER_GROUPS_SUB_PATH = "user_groups" USER_GROUPS_SUB_PATH = "user_groups"
ACCESS_KEYS = "access_keys"
class Role(object): class Role(object):
@ -123,7 +123,8 @@ def update_sub_account(sub_account_id, name=None, cloud_name=None, custom_attrib
return _call_account_api("put", uri, params=params, **options) return _call_account_api("put", uri, params=params, **options)
def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, **options): def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, last_login=None, from_date=None, to_date=None,
**options):
""" """
List all users List all users
:param user_ids: The ids of the users to fetch :param user_ids: The ids of the users to fetch
@ -136,6 +137,13 @@ def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, **optio
:type pending: bool, optional :type pending: bool, optional
:param prefix: User prefix :param prefix: User prefix
:type prefix: str, optional :type prefix: str, optional
:param last_login: Return only users that last logged in in the specified range of dates (true),
users that didn't last logged in in that range (false), or all users (None).
:type last_login: bool, optional
:param from_date: Last login start date.
:type from_date: datetime, optional
:param to_date: Last login end date.
:type to_date: datetime, optional
:param options: Generic advanced options dict, see online documentation. :param options: Generic advanced options dict, see online documentation.
:type options: dict, optional :type options: dict, optional
:return: List of users associated with the account :return: List of users associated with the account
@ -146,7 +154,10 @@ def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, **optio
params = {"ids": user_ids, params = {"ids": user_ids,
"sub_account_id": sub_account_id, "sub_account_id": sub_account_id,
"pending": pending, "pending": pending,
"prefix": prefix} "prefix": prefix,
"last_login": last_login,
"from": from_date,
"to": to_date}
return _call_account_api("get", uri, params=params, **options) return _call_account_api("get", uri, params=params, **options)
@ -351,7 +362,7 @@ def user_in_user_groups(user_id, **options):
""" """
Get all user groups a user belongs to Get all user groups a user belongs to
:param user_id: The id of user :param user_id: The id of user
:param user_id: str :type user_id: str
:param options: Generic advanced options dict, see online documentation :param options: Generic advanced options dict, see online documentation
:type options: dict, optional :type options: dict, optional
:return: List of groups user is in :return: List of groups user is in
@ -359,3 +370,112 @@ def user_in_user_groups(user_id, **options):
""" """
uri = [USER_GROUPS_SUB_PATH, user_id] uri = [USER_GROUPS_SUB_PATH, user_id]
return _call_account_api("get", uri, {}, **options) return _call_account_api("get", uri, {}, **options)
def access_keys(sub_account_id, page_size=None, page=None, sort_by=None, sort_order=None, **options):
"""
Get sub account access keys.
:param sub_account_id: The id of the sub account.
:type sub_account_id: str
:param page_size: How many entries to display on each page.
:type page_size: int
:param page: Which page to return (maximum pages: 100). **Default**: All pages are returned.
:type page: int
:param sort_by: Which response parameter to sort by.
**Possible values**: `api_key`, `created_at`, `name`, `enabled`.
:type sort_by: str
:param sort_order: Control the order of returned keys. **Possible values**: `desc` (default), `asc`.
:type sort_order: str
:param options: Generic advanced options dict, see online documentation.
:type options: dict, optional
:return: List of access keys
:rtype: dict
"""
uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS]
params = {
"page_size": page_size,
"page": page,
"sort_by": sort_by,
"sort_order": sort_order,
}
return _call_account_api("get", uri, params, **options)
def generate_access_key(sub_account_id, name=None, enabled=None, **options):
"""
Generate a new access key.
:param sub_account_id: The id of the sub account.
:type sub_account_id: str
:param name: The name of the new access key.
:type name: str
:param enabled: Whether the new access key is enabled or disabled.
:type enabled: bool
:param options: Generic advanced options dict, see online documentation.
:type options: dict, optional
:return: Access key details
:rtype: dict
"""
uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS]
params = {
"name": name,
"enabled": enabled,
}
return _call_account_api("post", uri, params, **options)
def update_access_key(sub_account_id, api_key, name=None, enabled=None, dedicated_for=None, **options):
"""
Update the name and/or status of an existing access key.
:param sub_account_id: The id of the sub account.
:type sub_account_id: str
:param api_key: The API key of the access key.
:type api_key: str|int
:param name: The updated name of the access key.
:type name: str
:param enabled: Enable or disable the access key.
:type enabled: bool
:param dedicated_for: Designates the access key for a specific purpose while allowing it to be used for
other purposes, as well. This action replaces any previously assigned key.
**Possible values**: `webhooks`
:type dedicated_for: str
:param options: Generic advanced options dict, see online documentation.
:type options: dict, optional
:return: Access key details
:rtype: dict
"""
uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS, str(api_key)]
params = {
"name": name,
"enabled": enabled,
"dedicated_for": dedicated_for,
}
return _call_account_api("put", uri, params, **options)
def delete_access_key(sub_account_id, api_key=None, name=None, **options):
"""
Delete an existing access key by api_key or by name.
:param sub_account_id: The id of the sub account.
:type sub_account_id: str
:param api_key: The API key of the access key.
:type api_key: str|int
:param name: The name of the access key.
:type name: str
:param options: Generic advanced options dict, see online documentation.
:type options: dict, optional
:return: Operation status.
:rtype: dict
"""
uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS]
if api_key is not None:
uri.append(str(api_key))
params = {
"name": name
}
return _call_account_api("delete", uri, params, **options)

View file

@ -3,8 +3,8 @@ import json
import cloudinary import cloudinary
from cloudinary.api_client.call_api import call_json_api from cloudinary.api_client.call_api import call_json_api
from cloudinary.utils import unique, unsigned_download_url_prefix, build_distribution_domain, base64url_encode, \ from cloudinary.utils import (unique, build_distribution_domain, base64url_encode, json_encode, compute_hex_hash,
json_encode, compute_hex_hash, SIGNATURE_SHA256 SIGNATURE_SHA256, build_array)
class Search(object): class Search(object):
@ -16,6 +16,7 @@ class Search(object):
'sort_by': lambda x: next(iter(x)), 'sort_by': lambda x: next(iter(x)),
'aggregate': None, 'aggregate': None,
'with_field': None, 'with_field': None,
'fields': None,
} }
_ttl = 300 # Used for search URLs _ttl = 300 # Used for search URLs
@ -57,6 +58,11 @@ class Search(object):
self._add("with_field", value) self._add("with_field", value)
return self return self
def fields(self, value):
"""Request which fields to return in the result set."""
self._add("fields", value)
return self
def ttl(self, ttl): def ttl(self, ttl):
""" """
Sets the time to live of the search URL. Sets the time to live of the search URL.
@ -133,5 +139,5 @@ class Search(object):
def _add(self, name, value): def _add(self, name, value):
if name not in self.query: if name not in self.query:
self.query[name] = [] self.query[name] = []
self.query[name].append(value) self.query[name].extend(build_array(value))
return self return self

View file

@ -23,11 +23,6 @@ try: # Python 2.7+
except ImportError: except ImportError:
from urllib3.packages.ordered_dict import OrderedDict from urllib3.packages.ordered_dict import OrderedDict
try: # Python 3.4+
from pathlib import Path as PathLibPathType
except ImportError:
PathLibPathType = None
if is_appengine_sandbox(): if is_appengine_sandbox():
# AppEngineManager uses AppEngine's URLFetch API behind the scenes # AppEngineManager uses AppEngine's URLFetch API behind the scenes
_http = AppEngineManager() _http = AppEngineManager()
@ -503,32 +498,7 @@ def call_api(action, params, http_headers=None, return_error=False, unsigned=Fal
if file: if file:
filename = options.get("filename") # Custom filename provided by user (relevant only for streams and files) filename = options.get("filename") # Custom filename provided by user (relevant only for streams and files)
param_list.append(("file", utils.handle_file_parameter(file, filename)))
if PathLibPathType and isinstance(file, PathLibPathType):
name = filename or file.name
data = file.read_bytes()
elif isinstance(file, string_types):
if utils.is_remote_url(file):
# URL
name = None
data = file
else:
# file path
name = filename or file
with open(file, "rb") as opened:
data = opened.read()
elif hasattr(file, 'read') and callable(file.read):
# stream
data = file.read()
name = filename or (file.name if hasattr(file, 'name') and isinstance(file.name, str) else "stream")
elif isinstance(file, tuple):
name, data = file
else:
# Not a string, not a stream
name = filename or "file"
data = file
param_list.append(("file", (name, data) if name else data))
kw = {} kw = {}
if timeout is not None: if timeout is not None:
@ -536,7 +506,7 @@ def call_api(action, params, http_headers=None, return_error=False, unsigned=Fal
code = 200 code = 200
try: try:
response = _http.request("POST", api_url, param_list, headers, **kw) response = _http.request(method="POST", url=api_url, fields=param_list, headers=headers, **kw)
except HTTPError as e: except HTTPError as e:
raise Error("Unexpected error - {0!r}".format(e)) raise Error("Unexpected error - {0!r}".format(e))
except socket.error as e: except socket.error as e:

View file

@ -25,6 +25,11 @@ from cloudinary import auth_token
from cloudinary.api_client.tcp_keep_alive_manager import TCPKeepAlivePoolManager, TCPKeepAliveProxyManager from cloudinary.api_client.tcp_keep_alive_manager import TCPKeepAlivePoolManager, TCPKeepAliveProxyManager
from cloudinary.compat import PY3, to_bytes, to_bytearray, to_string, string_types, urlparse from cloudinary.compat import PY3, to_bytes, to_bytearray, to_string, string_types, urlparse
try: # Python 3.4+
from pathlib import Path as PathLibPathType
except ImportError:
PathLibPathType = None
VAR_NAME_RE = r'(\$\([a-zA-Z]\w+\))' VAR_NAME_RE = r'(\$\([a-zA-Z]\w+\))'
urlencode = six.moves.urllib.parse.urlencode urlencode = six.moves.urllib.parse.urlencode
@ -127,6 +132,7 @@ __SERIALIZED_UPLOAD_PARAMS = [
"allowed_formats", "allowed_formats",
"face_coordinates", "face_coordinates",
"custom_coordinates", "custom_coordinates",
"regions",
"context", "context",
"auto_tagging", "auto_tagging",
"responsive_breakpoints", "responsive_breakpoints",
@ -181,12 +187,11 @@ def compute_hex_hash(s, algorithm=SIGNATURE_SHA1):
def build_array(arg): def build_array(arg):
if isinstance(arg, list): if isinstance(arg, (list, tuple)):
return arg return arg
elif arg is None: elif arg is None:
return [] return []
else: return [arg]
return [arg]
def build_list_of_dicts(val): def build_list_of_dicts(val):
@ -235,8 +240,7 @@ def encode_double_array(array):
array = build_array(array) array = build_array(array)
if len(array) > 0 and isinstance(array[0], list): if len(array) > 0 and isinstance(array[0], list):
return "|".join([",".join([str(i) for i in build_array(inner)]) for inner in array]) return "|".join([",".join([str(i) for i in build_array(inner)]) for inner in array])
else: return encode_list([str(i) for i in array])
return encode_list([str(i) for i in array])
def encode_dict(arg): def encode_dict(arg):
@ -246,8 +250,7 @@ def encode_dict(arg):
else: else:
items = arg.iteritems() items = arg.iteritems()
return "|".join((k + "=" + v) for k, v in items) return "|".join((k + "=" + v) for k, v in items)
else: return arg
return arg
def normalize_context_value(value): def normalize_context_value(value):
@ -288,9 +291,14 @@ def json_encode(value, sort_keys=False):
Converts value to a json encoded string Converts value to a json encoded string
:param value: value to be encoded :param value: value to be encoded
:param sort_keys: whether to sort keys
:return: JSON encoded string :return: JSON encoded string
""" """
if isinstance(value, str) or value is None:
return value
return json.dumps(value, default=__json_serializer, separators=(',', ':'), sort_keys=sort_keys) return json.dumps(value, default=__json_serializer, separators=(',', ':'), sort_keys=sort_keys)
@ -309,11 +317,13 @@ def patch_fetch_format(options):
""" """
When upload type is fetch, remove the format options. When upload type is fetch, remove the format options.
In addition, set the fetch_format options to the format value unless it was already set. In addition, set the fetch_format options to the format value unless it was already set.
Mutates the options parameter! Mutates the "options" parameter!
:param options: URL and transformation options :param options: URL and transformation options
""" """
if options.get("type", "upload") != "fetch": use_fetch_format = options.pop("use_fetch_format", cloudinary.config().use_fetch_format)
if options.get("type", "upload") != "fetch" and not use_fetch_format:
return return
resource_format = options.pop("format", None) resource_format = options.pop("format", None)
@ -351,8 +361,7 @@ def generate_transformation_string(**options):
def recurse(bs): def recurse(bs):
if isinstance(bs, dict): if isinstance(bs, dict):
return generate_transformation_string(**bs)[0] return generate_transformation_string(**bs)[0]
else: return generate_transformation_string(transformation=bs)[0]
return generate_transformation_string(transformation=bs)[0]
base_transformations = list(map(recurse, base_transformations)) base_transformations = list(map(recurse, base_transformations))
named_transformation = None named_transformation = None
@ -375,7 +384,7 @@ def generate_transformation_string(**options):
flags = ".".join(build_array(options.pop("flags", None))) flags = ".".join(build_array(options.pop("flags", None)))
dpr = options.pop("dpr", cloudinary.config().dpr) dpr = options.pop("dpr", cloudinary.config().dpr)
duration = norm_range_value(options.pop("duration", None)) duration = norm_range_value(options.pop("duration", None))
so_raw = options.pop("start_offset", None) so_raw = options.pop("start_offset", None)
start_offset = norm_auto_range_value(so_raw) start_offset = norm_auto_range_value(so_raw)
if start_offset == None: if start_offset == None:
@ -513,8 +522,7 @@ def split_range(range):
return [range[0], range[-1]] return [range[0], range[-1]]
elif isinstance(range, string_types) and re.match(RANGE_RE, range): elif isinstance(range, string_types) and re.match(RANGE_RE, range):
return range.split("..", 1) return range.split("..", 1)
else: return None
return None
def norm_range_value(value): def norm_range_value(value):
@ -570,6 +578,9 @@ def process_params(params):
processed_params = {} processed_params = {}
for key, value in params.items(): for key, value in params.items():
if isinstance(value, list) or isinstance(value, tuple): if isinstance(value, list) or isinstance(value, tuple):
if len(value) == 2 and value[0] == "file": # keep file parameter as is.
processed_params[key] = value
continue
value_list = {"{}[{}]".format(key, i): i_value for i, i_value in enumerate(value)} value_list = {"{}[{}]".format(key, i): i_value for i, i_value in enumerate(value)}
processed_params.update(value_list) processed_params.update(value_list)
elif value is not None: elif value is not None:
@ -578,9 +589,28 @@ def process_params(params):
def cleanup_params(params): def cleanup_params(params):
"""
Cleans and normalizes parameters when calculating signature in Upload API.
:param params:
:return:
"""
return dict([(k, __safe_value(v)) for (k, v) in params.items() if v is not None and not v == ""]) return dict([(k, __safe_value(v)) for (k, v) in params.items() if v is not None and not v == ""])
def normalize_params(params):
"""
Normalizes Admin API parameters.
:param params:
:return:
"""
if not params or not isinstance(params, dict):
return params
return dict([(k, __bool_string(v)) for (k, v) in params.items() if v is not None and not v == ""])
def sign_request(params, options): def sign_request(params, options):
api_key = options.get("api_key", cloudinary.config().api_key) api_key = options.get("api_key", cloudinary.config().api_key)
if not api_key: if not api_key:
@ -1086,6 +1116,7 @@ def build_upload_params(**options):
"allowed_formats": options.get("allowed_formats") and encode_list(build_array(options["allowed_formats"])), "allowed_formats": options.get("allowed_formats") and encode_list(build_array(options["allowed_formats"])),
"face_coordinates": encode_double_array(options.get("face_coordinates")), "face_coordinates": encode_double_array(options.get("face_coordinates")),
"custom_coordinates": encode_double_array(options.get("custom_coordinates")), "custom_coordinates": encode_double_array(options.get("custom_coordinates")),
"regions": json_encode(options.get("regions")),
"context": encode_context(options.get("context")), "context": encode_context(options.get("context")),
"auto_tagging": options.get("auto_tagging") and str(options.get("auto_tagging")), "auto_tagging": options.get("auto_tagging") and str(options.get("auto_tagging")),
"responsive_breakpoints": generate_responsive_breakpoints_string(options.get("responsive_breakpoints")), "responsive_breakpoints": generate_responsive_breakpoints_string(options.get("responsive_breakpoints")),
@ -1101,6 +1132,37 @@ def build_upload_params(**options):
return params return params
def handle_file_parameter(file, filename):
if not file:
return None
if PathLibPathType and isinstance(file, PathLibPathType):
name = filename or file.name
data = file.read_bytes()
elif isinstance(file, string_types):
if is_remote_url(file):
# URL
name = None
data = file
else:
# file path
name = filename or file
with open(file, "rb") as opened:
data = opened.read()
elif hasattr(file, 'read') and callable(file.read):
# stream
data = file.read()
name = filename or (file.name if hasattr(file, 'name') and isinstance(file.name, str) else "stream")
elif isinstance(file, tuple):
name, data = file
else:
# Not a string, not a stream
name = filename or "file"
data = file
return (name, data) if name else data
def build_multi_and_sprite_params(**options): def build_multi_and_sprite_params(**options):
""" """
Build params for multi, download_multi, generate_sprite, and download_generated_sprite methods Build params for multi, download_multi, generate_sprite, and download_generated_sprite methods
@ -1166,8 +1228,21 @@ def __process_text_options(layer, layer_parameter):
def process_layer(layer, layer_parameter): def process_layer(layer, layer_parameter):
if isinstance(layer, string_types) and layer.startswith("fetch:"): if isinstance(layer, string_types):
layer = {"url": layer[len('fetch:'):]} resource_type = None
if layer.startswith("fetch:"):
url = layer[len('fetch:'):]
elif layer.find(":fetch:", 0, 12) != -1:
resource_type, _, url = layer.split(":", 2)
else:
# nothing to process, a raw string, keep as is.
return layer
# handle remote fetch URL
layer = {"url": url, "type": "fetch"}
if resource_type:
layer["resource_type"] = resource_type
if not isinstance(layer, dict): if not isinstance(layer, dict):
return layer return layer
@ -1176,19 +1251,19 @@ def process_layer(layer, layer_parameter):
type = layer.get("type") type = layer.get("type")
public_id = layer.get("public_id") public_id = layer.get("public_id")
format = layer.get("format") format = layer.get("format")
fetch = layer.get("url") fetch_url = layer.get("url")
components = list() components = list()
if text is not None and resource_type is None: if text is not None and resource_type is None:
resource_type = "text" resource_type = "text"
if fetch and resource_type is None: if fetch_url and type is None:
resource_type = "fetch" type = "fetch"
if public_id is not None and format is not None: if public_id is not None and format is not None:
public_id = public_id + "." + format public_id = public_id + "." + format
if public_id is None and resource_type != "text" and resource_type != "fetch": if public_id is None and resource_type != "text" and type != "fetch":
raise ValueError("Must supply public_id for for non-text " + layer_parameter) raise ValueError("Must supply public_id for for non-text " + layer_parameter)
if resource_type is not None and resource_type != "image": if resource_type is not None and resource_type != "image":
@ -1212,8 +1287,6 @@ def process_layer(layer, layer_parameter):
if text is not None: if text is not None:
var_pattern = VAR_NAME_RE var_pattern = VAR_NAME_RE
match = re.findall(var_pattern, text)
parts = filter(lambda p: p is not None, re.split(var_pattern, text)) parts = filter(lambda p: p is not None, re.split(var_pattern, text))
encoded_text = [] encoded_text = []
for part in parts: for part in parts:
@ -1223,11 +1296,9 @@ def process_layer(layer, layer_parameter):
encoded_text.append(smart_escape(smart_escape(part, r"([,/])"))) encoded_text.append(smart_escape(smart_escape(part, r"([,/])")))
text = ''.join(encoded_text) text = ''.join(encoded_text)
# text = text.replace("%2C", "%252C")
# text = text.replace("/", "%252F")
components.append(text) components.append(text)
elif resource_type == "fetch": elif type == "fetch":
b64 = base64_encode_url(fetch) b64 = base64url_encode(fetch_url)
components.append(b64) components.append(b64)
else: else:
public_id = public_id.replace("/", ':') public_id = public_id.replace("/", ':')
@ -1359,8 +1430,7 @@ def normalize_expression(expression):
result = re.sub(replaceRE, translate_if, result) result = re.sub(replaceRE, translate_if, result)
result = re.sub('[ _]+', '_', result) result = re.sub('[ _]+', '_', result)
return result return result
else: return expression
return expression
def __join_pair(key, value): def __join_pair(key, value):
@ -1368,8 +1438,7 @@ def __join_pair(key, value):
return None return None
elif value is True: elif value is True:
return key return key
else: return u"{0}=\"{1}\"".format(key, value)
return u"{0}=\"{1}\"".format(key, value)
def html_attrs(attrs, only=None): def html_attrs(attrs, only=None):
@ -1379,10 +1448,15 @@ def html_attrs(attrs, only=None):
def __safe_value(v): def __safe_value(v):
if isinstance(v, bool): if isinstance(v, bool):
return "1" if v else "0" return "1" if v else "0"
else: return v
return v
def __bool_string(v):
if isinstance(v, bool):
return "true" if v else "false"
return v
def __crc(source): def __crc(source):
return str((zlib.crc32(to_bytearray(source)) & 0xffffffff) % 5 + 1) return str((zlib.crc32(to_bytearray(source)) & 0xffffffff) % 5 + 1)

View file

@ -0,0 +1,6 @@
version = "2.9.*"
upstream_repository = "https://github.com/dateutil/dateutil"
partial_stub = true
[tool.stubtest]
ignore_missing_stub = true

View file

@ -0,0 +1,9 @@
from typing_extensions import Self
class weekday:
def __init__(self, weekday: int, n: int | None = None) -> None: ...
def __call__(self, n: int) -> Self: ...
def __eq__(self, other: object) -> bool: ...
def __hash__(self) -> int: ...
weekday: int
n: int

View file

@ -0,0 +1,8 @@
from datetime import date
from typing import Literal
EASTER_JULIAN: Literal[1]
EASTER_ORTHODOX: Literal[2]
EASTER_WESTERN: Literal[3]
def easter(year: int, method: Literal[1, 2, 3] = 3) -> date: ...

View file

@ -0,0 +1,67 @@
from collections.abc import Callable, Mapping
from datetime import datetime, tzinfo
from typing import IO, Any
from typing_extensions import TypeAlias
from .isoparser import isoparse as isoparse, isoparser as isoparser
_FileOrStr: TypeAlias = bytes | str | IO[str] | IO[Any]
_TzData: TypeAlias = tzinfo | int | str | None
_TzInfo: TypeAlias = Mapping[str, _TzData] | Callable[[str, int], _TzData]
class parserinfo:
JUMP: list[str]
WEEKDAYS: list[tuple[str, ...]]
MONTHS: list[tuple[str, ...]]
HMS: list[tuple[str, str, str]]
AMPM: list[tuple[str, str]]
UTCZONE: list[str]
PERTAIN: list[str]
TZOFFSET: dict[str, int]
def __init__(self, dayfirst: bool = False, yearfirst: bool = False) -> None: ...
def jump(self, name: str) -> bool: ...
def weekday(self, name: str) -> int | None: ...
def month(self, name: str) -> int | None: ...
def hms(self, name: str) -> int | None: ...
def ampm(self, name: str) -> int | None: ...
def pertain(self, name: str) -> bool: ...
def utczone(self, name: str) -> bool: ...
def tzoffset(self, name: str) -> int | None: ...
def convertyear(self, year: int) -> int: ...
def validate(self, res: datetime) -> bool: ...
class parser:
def __init__(self, info: parserinfo | None = None) -> None: ...
def parse(
self,
timestr: _FileOrStr,
default: datetime | None = None,
ignoretz: bool = False,
tzinfos: _TzInfo | None = None,
*,
dayfirst: bool | None = ...,
yearfirst: bool | None = ...,
fuzzy: bool = ...,
fuzzy_with_tokens: bool = ...,
) -> datetime: ...
DEFAULTPARSER: parser
def parse(
timestr: _FileOrStr,
parserinfo: parserinfo | None = None,
*,
dayfirst: bool | None = ...,
yearfirst: bool | None = ...,
ignoretz: bool = ...,
fuzzy: bool = ...,
fuzzy_with_tokens: bool = ...,
default: datetime | None = ...,
tzinfos: _TzInfo | None = ...,
) -> datetime: ...
class _tzparser: ...
DEFAULTTZPARSER: _tzparser
class ParserError(ValueError): ...

View file

@ -0,0 +1,15 @@
from _typeshed import SupportsRead
from datetime import date, datetime, time, tzinfo
from typing_extensions import TypeAlias
_Readable: TypeAlias = SupportsRead[str | bytes]
_TakesAscii: TypeAlias = str | bytes | _Readable
class isoparser:
def __init__(self, sep: str | bytes | None = None): ...
def isoparse(self, dt_str: _TakesAscii) -> datetime: ...
def parse_isodate(self, datestr: _TakesAscii) -> date: ...
def parse_isotime(self, timestr: _TakesAscii) -> time: ...
def parse_tzstr(self, tzstr: _TakesAscii, zero_as_utc: bool = True) -> tzinfo: ...
def isoparse(dt_str: _TakesAscii) -> datetime: ...

View file

@ -0,0 +1 @@
partial

View file

@ -0,0 +1,97 @@
from datetime import date, timedelta
from typing import SupportsFloat, TypeVar, overload
from typing_extensions import Self, TypeAlias
# See #9817 for why we reexport this here
from ._common import weekday as weekday
_DateT = TypeVar("_DateT", bound=date)
# Work around attribute and type having the same name.
_Weekday: TypeAlias = weekday
MO: weekday
TU: weekday
WE: weekday
TH: weekday
FR: weekday
SA: weekday
SU: weekday
class relativedelta:
years: int
months: int
days: int
leapdays: int
hours: int
minutes: int
seconds: int
microseconds: int
year: int | None
month: int | None
weekday: _Weekday | None
day: int | None
hour: int | None
minute: int | None
second: int | None
microsecond: int | None
def __init__(
self,
dt1: date | None = None,
dt2: date | None = None,
years: int | None = 0,
months: int | None = 0,
days: int | None = 0,
leapdays: int | None = 0,
weeks: int | None = 0,
hours: int | None = 0,
minutes: int | None = 0,
seconds: int | None = 0,
microseconds: int | None = 0,
year: int | None = None,
month: int | None = None,
day: int | None = None,
weekday: int | _Weekday | None = None,
yearday: int | None = None,
nlyearday: int | None = None,
hour: int | None = None,
minute: int | None = None,
second: int | None = None,
microsecond: int | None = None,
) -> None: ...
@property
def weeks(self) -> int: ...
@weeks.setter
def weeks(self, value: int) -> None: ...
def normalized(self) -> Self: ...
# TODO: use Union when mypy will handle it properly in overloaded operator
# methods (#2129, #1442, #1264 in mypy)
@overload
def __add__(self, other: relativedelta) -> Self: ...
@overload
def __add__(self, other: timedelta) -> Self: ...
@overload
def __add__(self, other: _DateT) -> _DateT: ...
@overload
def __radd__(self, other: relativedelta) -> Self: ...
@overload
def __radd__(self, other: timedelta) -> Self: ...
@overload
def __radd__(self, other: _DateT) -> _DateT: ...
@overload
def __rsub__(self, other: relativedelta) -> Self: ...
@overload
def __rsub__(self, other: timedelta) -> Self: ...
@overload
def __rsub__(self, other: _DateT) -> _DateT: ...
def __sub__(self, other: relativedelta) -> Self: ...
def __neg__(self) -> Self: ...
def __bool__(self) -> bool: ...
def __nonzero__(self) -> bool: ...
def __mul__(self, other: SupportsFloat) -> Self: ...
def __rmul__(self, other: SupportsFloat) -> Self: ...
def __eq__(self, other: object) -> bool: ...
def __ne__(self, other: object) -> bool: ...
def __div__(self, other: SupportsFloat) -> Self: ...
def __truediv__(self, other: SupportsFloat) -> Self: ...
def __abs__(self) -> Self: ...
def __hash__(self) -> int: ...

View file

@ -0,0 +1,111 @@
import datetime
from _typeshed import Incomplete
from collections.abc import Iterable, Iterator, Sequence
from typing_extensions import TypeAlias
from ._common import weekday as weekdaybase
YEARLY: int
MONTHLY: int
WEEKLY: int
DAILY: int
HOURLY: int
MINUTELY: int
SECONDLY: int
class weekday(weekdaybase): ...
weekdays: tuple[weekday, weekday, weekday, weekday, weekday, weekday, weekday]
MO: weekday
TU: weekday
WE: weekday
TH: weekday
FR: weekday
SA: weekday
SU: weekday
class rrulebase:
def __init__(self, cache: bool = False) -> None: ...
def __iter__(self) -> Iterator[datetime.datetime]: ...
def __getitem__(self, item): ...
def __contains__(self, item): ...
def count(self): ...
def before(self, dt, inc: bool = False): ...
def after(self, dt, inc: bool = False): ...
def xafter(self, dt, count: Incomplete | None = None, inc: bool = False): ...
def between(self, after, before, inc: bool = False, count: int = 1): ...
class rrule(rrulebase):
def __init__(
self,
freq,
dtstart: datetime.date | None = None,
interval: int = 1,
wkst: weekday | int | None = None,
count: int | None = None,
until: datetime.date | int | None = None,
bysetpos: int | Iterable[int] | None = None,
bymonth: int | Iterable[int] | None = None,
bymonthday: int | Iterable[int] | None = None,
byyearday: int | Iterable[int] | None = None,
byeaster: int | Iterable[int] | None = None,
byweekno: int | Iterable[int] | None = None,
byweekday: int | weekday | Iterable[int] | Iterable[weekday] | None = None,
byhour: int | Iterable[int] | None = None,
byminute: int | Iterable[int] | None = None,
bysecond: int | Iterable[int] | None = None,
cache: bool = False,
) -> None: ...
def replace(self, **kwargs): ...
_RRule: TypeAlias = rrule
class _iterinfo:
rrule: _RRule
def __init__(self, rrule: _RRule) -> None: ...
yearlen: int | None
nextyearlen: int | None
yearordinal: int | None
yearweekday: int | None
mmask: Sequence[int] | None
mdaymask: Sequence[int] | None
nmdaymask: Sequence[int] | None
wdaymask: Sequence[int] | None
mrange: Sequence[int] | None
wnomask: Sequence[int] | None
nwdaymask: Sequence[int] | None
eastermask: Sequence[int] | None
lastyear: int | None
lastmonth: int | None
def rebuild(self, year, month): ...
def ydayset(self, year, month, day): ...
def mdayset(self, year, month, day): ...
def wdayset(self, year, month, day): ...
def ddayset(self, year, month, day): ...
def htimeset(self, hour, minute, second): ...
def mtimeset(self, hour, minute, second): ...
def stimeset(self, hour, minute, second): ...
class rruleset(rrulebase):
class _genitem:
dt: Incomplete
genlist: list[Incomplete]
gen: Incomplete
def __init__(self, genlist, gen) -> None: ...
def __next__(self) -> None: ...
next = __next__
def __lt__(self, other) -> bool: ...
def __gt__(self, other) -> bool: ...
def __eq__(self, other) -> bool: ...
def __ne__(self, other) -> bool: ...
def __init__(self, cache: bool = False) -> None: ...
def rrule(self, rrule: _RRule): ...
def rdate(self, rdate): ...
def exrule(self, exrule): ...
def exdate(self, exdate): ...
class _rrulestr:
def __call__(self, s, **kwargs) -> rrule | rruleset: ...
rrulestr: _rrulestr

View file

@ -0,0 +1,15 @@
from .tz import (
datetime_ambiguous as datetime_ambiguous,
datetime_exists as datetime_exists,
gettz as gettz,
resolve_imaginary as resolve_imaginary,
tzfile as tzfile,
tzical as tzical,
tzlocal as tzlocal,
tzoffset as tzoffset,
tzrange as tzrange,
tzstr as tzstr,
tzutc as tzutc,
)
UTC: tzutc

View file

@ -0,0 +1,28 @@
import abc
from datetime import datetime, timedelta, tzinfo
from typing import ClassVar
def tzname_in_python2(namefunc): ...
def enfold(dt: datetime, fold: int = 1): ...
class _DatetimeWithFold(datetime):
@property
def fold(self): ...
# Doesn't actually have ABCMeta as the metaclass at runtime,
# but mypy complains if we don't have it in the stub.
# See discussion in #8908
class _tzinfo(tzinfo, metaclass=abc.ABCMeta):
def is_ambiguous(self, dt: datetime) -> bool: ...
def fromutc(self, dt: datetime) -> datetime: ...
class tzrangebase(_tzinfo):
def __init__(self) -> None: ...
def utcoffset(self, dt: datetime | None) -> timedelta | None: ...
def dst(self, dt: datetime | None) -> timedelta | None: ...
def tzname(self, dt: datetime | None) -> str: ...
def fromutc(self, dt: datetime) -> datetime: ...
def is_ambiguous(self, dt: datetime) -> bool: ...
__hash__: ClassVar[None] # type: ignore[assignment]
def __ne__(self, other): ...
__reduce__ = object.__reduce__

View file

@ -0,0 +1,115 @@
import datetime
from _typeshed import Incomplete
from typing import ClassVar, Literal, Protocol, TypeVar
from ..relativedelta import relativedelta
from ._common import _tzinfo as _tzinfo, enfold as enfold, tzname_in_python2 as tzname_in_python2, tzrangebase as tzrangebase
_DT = TypeVar("_DT", bound=datetime.datetime)
ZERO: datetime.timedelta
EPOCH: datetime.datetime
EPOCHORDINAL: int
class tzutc(datetime.tzinfo):
def utcoffset(self, dt: datetime.datetime | None) -> datetime.timedelta | None: ...
def dst(self, dt: datetime.datetime | None) -> datetime.timedelta | None: ...
def tzname(self, dt: datetime.datetime | None) -> str: ...
def is_ambiguous(self, dt: datetime.datetime | None) -> bool: ...
def fromutc(self, dt: _DT) -> _DT: ...
def __eq__(self, other): ...
__hash__: ClassVar[None] # type: ignore[assignment]
def __ne__(self, other): ...
__reduce__ = object.__reduce__
class tzoffset(datetime.tzinfo):
def __init__(self, name, offset) -> None: ...
def utcoffset(self, dt: datetime.datetime | None) -> datetime.timedelta | None: ...
def dst(self, dt: datetime.datetime | None) -> datetime.timedelta | None: ...
def is_ambiguous(self, dt: datetime.datetime | None) -> bool: ...
def tzname(self, dt: datetime.datetime | None) -> str: ...
def fromutc(self, dt: _DT) -> _DT: ...
def __eq__(self, other): ...
__hash__: ClassVar[None] # type: ignore[assignment]
def __ne__(self, other): ...
__reduce__ = object.__reduce__
@classmethod
def instance(cls, name, offset) -> tzoffset: ...
class tzlocal(_tzinfo):
def __init__(self) -> None: ...
def utcoffset(self, dt: datetime.datetime | None) -> datetime.timedelta | None: ...
def dst(self, dt: datetime.datetime | None) -> datetime.timedelta | None: ...
def tzname(self, dt: datetime.datetime | None) -> str: ...
def is_ambiguous(self, dt: datetime.datetime | None) -> bool: ...
def __eq__(self, other): ...
__hash__: ClassVar[None] # type: ignore[assignment]
def __ne__(self, other): ...
__reduce__ = object.__reduce__
class _ttinfo:
def __init__(self) -> None: ...
def __eq__(self, other): ...
__hash__: ClassVar[None] # type: ignore[assignment]
def __ne__(self, other): ...
class _TZFileReader(Protocol):
# optional attribute:
# name: str
def read(self, size: int, /) -> bytes: ...
def seek(self, target: int, whence: Literal[1], /) -> object: ...
class tzfile(_tzinfo):
def __init__(self, fileobj: str | _TZFileReader, filename: str | None = None) -> None: ...
def is_ambiguous(self, dt: datetime.datetime | None, idx: int | None = None) -> bool: ...
def utcoffset(self, dt: datetime.datetime | None) -> datetime.timedelta | None: ...
def dst(self, dt: datetime.datetime | None) -> datetime.timedelta | None: ...
def tzname(self, dt: datetime.datetime | None) -> str: ...
def __eq__(self, other): ...
__hash__: ClassVar[None] # type: ignore[assignment]
def __ne__(self, other): ...
def __reduce__(self): ...
def __reduce_ex__(self, protocol): ...
class tzrange(tzrangebase):
hasdst: bool
def __init__(
self,
stdabbr: str,
stdoffset: int | datetime.timedelta | None = None,
dstabbr: str | None = None,
dstoffset: int | datetime.timedelta | None = None,
start: relativedelta | None = None,
end: relativedelta | None = None,
) -> None: ...
def transitions(self, year: int) -> tuple[datetime.datetime, datetime.datetime]: ...
def __eq__(self, other): ...
class tzstr(tzrange):
hasdst: bool
def __init__(self, s: str, posix_offset: bool = False) -> None: ...
@classmethod
def instance(cls, name, offset) -> tzoffset: ...
class _ICalReader(Protocol):
# optional attribute:
# name: str
def read(self) -> str: ...
class tzical:
def __init__(self, fileobj: str | _ICalReader) -> None: ...
def keys(self): ...
def get(self, tzid: Incomplete | None = None): ...
TZFILES: list[str]
TZPATHS: list[str]
def datetime_exists(dt: datetime.datetime, tz: datetime.tzinfo | None = None) -> bool: ...
def datetime_ambiguous(dt: datetime.datetime, tz: datetime.tzinfo | None = None) -> bool: ...
def resolve_imaginary(dt: datetime.datetime) -> datetime.datetime: ...
class _GetTZ:
def __call__(self, name: str | None = ...) -> datetime.tzinfo | None: ...
def nocache(self, name: str | None) -> datetime.tzinfo | None: ...
gettz: _GetTZ

View file

@ -0,0 +1,5 @@
from datetime import datetime, timedelta, tzinfo
def default_tzinfo(dt: datetime, tzinfo: tzinfo) -> datetime: ...
def today(tzinfo: tzinfo | None = None) -> datetime: ...
def within_delta(dt1: datetime, dt2: datetime, delta: timedelta) -> bool: ...

View file

@ -0,0 +1,17 @@
from _typeshed import Incomplete
from typing import IO
from typing_extensions import TypeAlias
__all__ = ["get_zonefile_instance", "gettz", "gettz_db_metadata"]
_MetadataType: TypeAlias = dict[str, Incomplete]
class ZoneInfoFile:
zones: dict[Incomplete, Incomplete]
metadata: _MetadataType | None
def __init__(self, zonefile_stream: IO[bytes] | None = None) -> None: ...
def get(self, name, default: Incomplete | None = None): ...
def get_zonefile_instance(new_instance: bool = False) -> ZoneInfoFile: ...
def gettz(name): ...
def gettz_db_metadata() -> _MetadataType: ...

View file

@ -0,0 +1,11 @@
from _typeshed import Incomplete, StrOrBytesPath
from collections.abc import Sequence
from tarfile import TarInfo
def rebuild(
filename: StrOrBytesPath,
tag: Incomplete | None = None,
format: str = "gz",
zonegroups: Sequence[str | TarInfo] = [],
metadata: Incomplete | None = None,
) -> None: ...

View file

@ -1,4 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import sys
try: try:
from ._version import version as __version__ from ._version import version as __version__
except ImportError: except ImportError:
@ -6,3 +8,17 @@ except ImportError:
__all__ = ['easter', 'parser', 'relativedelta', 'rrule', 'tz', __all__ = ['easter', 'parser', 'relativedelta', 'rrule', 'tz',
'utils', 'zoneinfo'] 'utils', 'zoneinfo']
def __getattr__(name):
import importlib
if name in __all__:
return importlib.import_module("." + name, __name__)
raise AttributeError(
"module {!r} has not attribute {!r}".format(__name__, name)
)
def __dir__():
# __dir__ should include all the lazy-importable modules as well.
return [x for x in globals() if x not in sys.modules] + __all__

View file

@ -1,5 +1,4 @@
# coding: utf-8
# file generated by setuptools_scm # file generated by setuptools_scm
# don't change, don't track in version control # don't change, don't track in version control
version = '2.8.2' __version__ = version = '2.9.0.post0'
version_tuple = (2, 8, 2) __version_tuple__ = version_tuple = (2, 9, 0)

View file

@ -72,7 +72,7 @@ class isoparser(object):
Common: Common:
- ``YYYY`` - ``YYYY``
- ``YYYY-MM`` or ``YYYYMM`` - ``YYYY-MM``
- ``YYYY-MM-DD`` or ``YYYYMMDD`` - ``YYYY-MM-DD`` or ``YYYYMMDD``
Uncommon: Uncommon:

View file

@ -48,7 +48,7 @@ class relativedelta(object):
the corresponding arithmetic operation on the original datetime value the corresponding arithmetic operation on the original datetime value
with the information in the relativedelta. with the information in the relativedelta.
weekday: weekday:
One of the weekday instances (MO, TU, etc) available in the One of the weekday instances (MO, TU, etc) available in the
relativedelta module. These instances may receive a parameter N, relativedelta module. These instances may receive a parameter N,
specifying the Nth weekday, which could be positive or negative specifying the Nth weekday, which could be positive or negative

View file

@ -182,7 +182,7 @@ class rrulebase(object):
# __len__() introduces a large performance penalty. # __len__() introduces a large performance penalty.
def count(self): def count(self):
""" Returns the number of recurrences in this set. It will have go """ Returns the number of recurrences in this set. It will have go
trough the whole recurrence, if this hasn't been done before. """ through the whole recurrence, if this hasn't been done before. """
if self._len is None: if self._len is None:
for x in self: for x in self:
pass pass

View file

@ -34,7 +34,7 @@ except ImportError:
from warnings import warn from warnings import warn
ZERO = datetime.timedelta(0) ZERO = datetime.timedelta(0)
EPOCH = datetime.datetime.utcfromtimestamp(0) EPOCH = datetime.datetime(1970, 1, 1, 0, 0)
EPOCHORDINAL = EPOCH.toordinal() EPOCHORDINAL = EPOCH.toordinal()

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python #!/usr/bin/env python
# Copyright 2015,2016,2017 Nir Cohen # Copyright 2015-2021 Nir Cohen
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -55,7 +55,7 @@ except ImportError:
# Python 3.7 # Python 3.7
TypedDict = dict TypedDict = dict
__version__ = "1.8.0" __version__ = "1.9.0"
class VersionDict(TypedDict): class VersionDict(TypedDict):
@ -125,6 +125,7 @@ _DISTRO_RELEASE_BASENAME_PATTERN = re.compile(r"(\w+)[-_](release|version)$")
# Base file names to be looked up for if _UNIXCONFDIR is not readable. # Base file names to be looked up for if _UNIXCONFDIR is not readable.
_DISTRO_RELEASE_BASENAMES = [ _DISTRO_RELEASE_BASENAMES = [
"SuSE-release", "SuSE-release",
"altlinux-release",
"arch-release", "arch-release",
"base-release", "base-release",
"centos-release", "centos-release",
@ -151,6 +152,8 @@ _DISTRO_RELEASE_IGNORE_BASENAMES = (
"system-release", "system-release",
"plesk-release", "plesk-release",
"iredmail-release", "iredmail-release",
"board-release",
"ec2_version",
) )
@ -243,6 +246,7 @@ def id() -> str:
"rocky" Rocky Linux "rocky" Rocky Linux
"aix" AIX "aix" AIX
"guix" Guix System "guix" Guix System
"altlinux" ALT Linux
============== ========================================= ============== =========================================
If you have a need to get distros for reliable IDs added into this set, If you have a need to get distros for reliable IDs added into this set,
@ -991,10 +995,10 @@ class LinuxDistribution:
For details, see :func:`distro.info`. For details, see :func:`distro.info`.
""" """
return dict( return InfoDict(
id=self.id(), id=self.id(),
version=self.version(pretty, best), version=self.version(pretty, best),
version_parts=dict( version_parts=VersionDict(
major=self.major_version(best), major=self.major_version(best),
minor=self.minor_version(best), minor=self.minor_version(best),
build_number=self.build_number(best), build_number=self.build_number(best),

View file

@ -16,7 +16,6 @@
""" """
import hashlib
from os import urandom from os import urandom
from base64 import b64encode, b64decode from base64 import b64encode, b64decode
from hashlib import pbkdf2_hmac from hashlib import pbkdf2_hmac
@ -30,7 +29,7 @@ HASH_FUNCTION = 'sha256' # Must be in hashlib.
# Linear to the hashing time. Adjust to be high but take a reasonable # Linear to the hashing time. Adjust to be high but take a reasonable
# amount of time on your server. Measure with: # amount of time on your server. Measure with:
# python -m timeit -s 'import passwords as p' 'p.make_hash("something")' # python -m timeit -s 'import passwords as p' 'p.make_hash("something")'
COST_FACTOR = 10000 COST_FACTOR = 600000
def make_hash(password): def make_hash(password):

View file

@ -1,7 +1,7 @@
from .core import encode, decode, alabel, ulabel, IDNAError from .core import encode, decode, alabel, ulabel, IDNAError
import codecs import codecs
import re import re
from typing import Tuple, Optional from typing import Any, Tuple, Optional
_unicode_dots_re = re.compile('[\u002e\u3002\uff0e\uff61]') _unicode_dots_re = re.compile('[\u002e\u3002\uff0e\uff61]')
@ -26,24 +26,24 @@ class Codec(codecs.Codec):
return decode(data), len(data) return decode(data), len(data)
class IncrementalEncoder(codecs.BufferedIncrementalEncoder): class IncrementalEncoder(codecs.BufferedIncrementalEncoder):
def _buffer_encode(self, data: str, errors: str, final: bool) -> Tuple[str, int]: # type: ignore def _buffer_encode(self, data: str, errors: str, final: bool) -> Tuple[bytes, int]:
if errors != 'strict': if errors != 'strict':
raise IDNAError('Unsupported error handling \"{}\"'.format(errors)) raise IDNAError('Unsupported error handling \"{}\"'.format(errors))
if not data: if not data:
return "", 0 return b'', 0
labels = _unicode_dots_re.split(data) labels = _unicode_dots_re.split(data)
trailing_dot = '' trailing_dot = b''
if labels: if labels:
if not labels[-1]: if not labels[-1]:
trailing_dot = '.' trailing_dot = b'.'
del labels[-1] del labels[-1]
elif not final: elif not final:
# Keep potentially unfinished label until the next call # Keep potentially unfinished label until the next call
del labels[-1] del labels[-1]
if labels: if labels:
trailing_dot = '.' trailing_dot = b'.'
result = [] result = []
size = 0 size = 0
@ -54,18 +54,21 @@ class IncrementalEncoder(codecs.BufferedIncrementalEncoder):
size += len(label) size += len(label)
# Join with U+002E # Join with U+002E
result_str = '.'.join(result) + trailing_dot # type: ignore result_bytes = b'.'.join(result) + trailing_dot
size += len(trailing_dot) size += len(trailing_dot)
return result_str, size return result_bytes, size
class IncrementalDecoder(codecs.BufferedIncrementalDecoder): class IncrementalDecoder(codecs.BufferedIncrementalDecoder):
def _buffer_decode(self, data: str, errors: str, final: bool) -> Tuple[str, int]: # type: ignore def _buffer_decode(self, data: Any, errors: str, final: bool) -> Tuple[str, int]:
if errors != 'strict': if errors != 'strict':
raise IDNAError('Unsupported error handling \"{}\"'.format(errors)) raise IDNAError('Unsupported error handling \"{}\"'.format(errors))
if not data: if not data:
return ('', 0) return ('', 0)
if not isinstance(data, str):
data = str(data, 'ascii')
labels = _unicode_dots_re.split(data) labels = _unicode_dots_re.split(data)
trailing_dot = '' trailing_dot = ''
if labels: if labels:
@ -99,14 +102,17 @@ class StreamReader(Codec, codecs.StreamReader):
pass pass
def getregentry() -> codecs.CodecInfo: def search_function(name: str) -> Optional[codecs.CodecInfo]:
# Compatibility as a search_function for codecs.register() if name != 'idna2008':
return None
return codecs.CodecInfo( return codecs.CodecInfo(
name='idna', name=name,
encode=Codec().encode, # type: ignore encode=Codec().encode,
decode=Codec().decode, # type: ignore decode=Codec().decode,
incrementalencoder=IncrementalEncoder, incrementalencoder=IncrementalEncoder,
incrementaldecoder=IncrementalDecoder, incrementaldecoder=IncrementalDecoder,
streamwriter=StreamWriter, streamwriter=StreamWriter,
streamreader=StreamReader, streamreader=StreamReader,
) )
codecs.register(search_function)

View file

@ -150,9 +150,11 @@ def valid_contextj(label: str, pos: int) -> bool:
joining_type = idnadata.joining_types.get(ord(label[i])) joining_type = idnadata.joining_types.get(ord(label[i]))
if joining_type == ord('T'): if joining_type == ord('T'):
continue continue
if joining_type in [ord('L'), ord('D')]: elif joining_type in [ord('L'), ord('D')]:
ok = True ok = True
break break
else:
break
if not ok: if not ok:
return False return False
@ -162,9 +164,11 @@ def valid_contextj(label: str, pos: int) -> bool:
joining_type = idnadata.joining_types.get(ord(label[i])) joining_type = idnadata.joining_types.get(ord(label[i]))
if joining_type == ord('T'): if joining_type == ord('T'):
continue continue
if joining_type in [ord('R'), ord('D')]: elif joining_type in [ord('R'), ord('D')]:
ok = True ok = True
break break
else:
break
return ok return ok
if cp_value == 0x200d: if cp_value == 0x200d:
@ -236,12 +240,8 @@ def check_label(label: Union[str, bytes, bytearray]) -> None:
if intranges_contain(cp_value, idnadata.codepoint_classes['PVALID']): if intranges_contain(cp_value, idnadata.codepoint_classes['PVALID']):
continue continue
elif intranges_contain(cp_value, idnadata.codepoint_classes['CONTEXTJ']): elif intranges_contain(cp_value, idnadata.codepoint_classes['CONTEXTJ']):
try: if not valid_contextj(label, pos):
if not valid_contextj(label, pos): raise InvalidCodepointContext('Joiner {} not allowed at position {} in {}'.format(
raise InvalidCodepointContext('Joiner {} not allowed at position {} in {}'.format(
_unot(cp_value), pos+1, repr(label)))
except ValueError:
raise IDNAError('Unknown codepoint adjacent to joiner {} at position {} in {}'.format(
_unot(cp_value), pos+1, repr(label))) _unot(cp_value), pos+1, repr(label)))
elif intranges_contain(cp_value, idnadata.codepoint_classes['CONTEXTO']): elif intranges_contain(cp_value, idnadata.codepoint_classes['CONTEXTO']):
if not valid_contexto(label, pos): if not valid_contexto(label, pos):
@ -262,13 +262,8 @@ def alabel(label: str) -> bytes:
except UnicodeEncodeError: except UnicodeEncodeError:
pass pass
if not label:
raise IDNAError('No Input')
label = str(label)
check_label(label) check_label(label)
label_bytes = _punycode(label) label_bytes = _alabel_prefix + _punycode(label)
label_bytes = _alabel_prefix + label_bytes
if not valid_label_length(label_bytes): if not valid_label_length(label_bytes):
raise IDNAError('Label too long') raise IDNAError('Label too long')
@ -318,7 +313,7 @@ def uts46_remap(domain: str, std3_rules: bool = True, transitional: bool = False
status = uts46row[1] status = uts46row[1]
replacement = None # type: Optional[str] replacement = None # type: Optional[str]
if len(uts46row) == 3: if len(uts46row) == 3:
replacement = uts46row[2] # type: ignore replacement = uts46row[2]
if (status == 'V' or if (status == 'V' or
(status == 'D' and not transitional) or (status == 'D' and not transitional) or
(status == '3' and not std3_rules and replacement is None)): (status == '3' and not std3_rules and replacement is None)):
@ -338,9 +333,9 @@ def uts46_remap(domain: str, std3_rules: bool = True, transitional: bool = False
def encode(s: Union[str, bytes, bytearray], strict: bool = False, uts46: bool = False, std3_rules: bool = False, transitional: bool = False) -> bytes: def encode(s: Union[str, bytes, bytearray], strict: bool = False, uts46: bool = False, std3_rules: bool = False, transitional: bool = False) -> bytes:
if isinstance(s, (bytes, bytearray)): if not isinstance(s, str):
try: try:
s = s.decode('ascii') s = str(s, 'ascii')
except UnicodeDecodeError: except UnicodeDecodeError:
raise IDNAError('should pass a unicode string to the function rather than a byte string.') raise IDNAError('should pass a unicode string to the function rather than a byte string.')
if uts46: if uts46:
@ -372,8 +367,8 @@ def encode(s: Union[str, bytes, bytearray], strict: bool = False, uts46: bool =
def decode(s: Union[str, bytes, bytearray], strict: bool = False, uts46: bool = False, std3_rules: bool = False) -> str: def decode(s: Union[str, bytes, bytearray], strict: bool = False, uts46: bool = False, std3_rules: bool = False) -> str:
try: try:
if isinstance(s, (bytes, bytearray)): if not isinstance(s, str):
s = s.decode('ascii') s = str(s, 'ascii')
except UnicodeDecodeError: except UnicodeDecodeError:
raise IDNAError('Invalid ASCII in A-label') raise IDNAError('Invalid ASCII in A-label')
if uts46: if uts46:

Some files were not shown because too many files have changed in this diff Show more