Compare commits

..

No commits in common. "master" and "v2.12.0-beta" have entirely different histories.

1073 changed files with 51062 additions and 109055 deletions

View file

@ -1,4 +0,0 @@
name: CodeQL Config
paths-ignore:
- lib

View file

@ -1,38 +0,0 @@
name: CodeQL
on:
push:
branches: [nightly]
pull_request:
branches: [nightly]
schedule:
- cron: '05 10 * * 1'
jobs:
codeql-analysis:
name: CodeQL Analysis
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ['javascript', 'python']
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
config-file: ./.github/codeql-config.yml
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View file

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

View file

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

View file

@ -13,7 +13,7 @@ jobs:
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Prepare
id: prepare
@ -33,20 +33,21 @@ jobs:
echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
fi
echo "commit=${GITHUB_SHA}" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
echo "docker_platforms=linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6" >> $GITHUB_OUTPUT
echo "docker_image=${{ secrets.DOCKER_REPO }}/tautulli" >> $GITHUB_OUTPUT
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
id: buildx
with:
version: latest
- name: Cache Docker Layers
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
@ -54,28 +55,22 @@ jobs:
${{ runner.os }}-buildx-
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v2
if: success()
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v2
if: success()
with:
registry: ghcr.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Extract Docker Metadata
id: metadata
uses: docker/metadata-action@v5
with:
images: ${{ steps.prepare.outputs.docker_image }}
- name: Docker Build and Push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v4
if: success()
with:
context: .
@ -86,10 +81,10 @@ jobs:
TAG=${{ steps.prepare.outputs.tag }}
BRANCH=${{ steps.prepare.outputs.branch }}
COMMIT=${{ steps.prepare.outputs.commit }}
BUILD_DATE=${{ steps.prepare.outputs.build_date }}
tags: |
${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.tag }}
ghcr.io/${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.tag }}
labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
@ -99,10 +94,23 @@ jobs:
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3.0
- 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
uses: sarisia/actions-status-discord@v1
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ needs.build-docker.result == 'success' && 'success' || contains(needs.*.result, 'failure') && 'failure' || 'cancelled' }}
status: ${{ steps.status.outputs.status }}
title: ${{ github.workflow }}
nofail: true

View file

@ -6,13 +6,10 @@ on:
branches: [master, beta, nightly]
tags: [v*]
env:
PYTHON_VERSION: '3.11'
jobs:
build-installer:
name: Build ${{ matrix.os_upper }} Installer
runs-on: ${{ matrix.os }}-${{ matrix.os_version }}
runs-on: ${{ matrix.os }}-latest
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
strategy:
fail-fast: false
@ -20,18 +17,14 @@ jobs:
include:
- os: 'windows'
os_upper: 'Windows'
os_version: 'latest'
arch: 'x64'
ext: 'exe'
- os: 'macos'
os_upper: 'MacOS'
os_version: '14'
arch: 'universal'
ext: 'pkg'
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Set Release Version
id: get_version
@ -59,29 +52,29 @@ jobs:
echo $GITHUB_SHA > version.txt
- name: Set Up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: ${{ env.PYTHON_VERSION }}
python-version: '3.9'
cache: pip
cache-dependency-path: '**/requirements*.txt'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r package/requirements-package.txt --no-binary cffi
pip install -r package/requirements-package.txt
- name: Build Package
run: |
pyinstaller -y ./package/Tautulli-${{ matrix.os }}.spec
- name: Create Windows Installer
uses: joncloud/makensis-action@v4.1
uses: joncloud/makensis-action@v3.7
if: matrix.os == 'windows'
with:
script-file: ./package/Tautulli.nsi
arguments: >
/DVERSION=${{ steps.get_version.outputs.VERSION_NSIS }}
/DINSTALLER_NAME=..\Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-${{ matrix.arch }}.${{ matrix.ext }}
/DINSTALLER_NAME=..\Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
additional-plugin-paths: package/nsis-plugins
- name: Create MacOS Installer
@ -92,31 +85,13 @@ jobs:
--version ${{ steps.get_version.outputs.VERSION }} \
--component ./dist/Tautulli.app \
--scripts ./package/macos-scripts \
Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-${{ matrix.arch }}.${{ matrix.ext }}
Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.pkg
- name: Upload Installer
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: Tautulli-${{ matrix.os }}-installer
path: Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-${{ matrix.arch }}.${{ matrix.ext }}
virus-total:
name: VirusTotal Scan
needs: build-installer
if: needs.build-installer.result == 'success' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest
steps:
- name: Download Installers
if: needs.build-installer.result == 'success'
uses: actions/download-artifact@v4
- name: Upload to VirusTotal
uses: crazy-max/ghaction-virustotal@v4
with:
vt_api_key: ${{ secrets.VT_API_KEY }}
files: |
Tautulli-windows-installer/Tautulli-windows-*-x64.exe
Tautulli-macos-installer/Tautulli-macos-*-universal.pkg
path: Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.${{ matrix.ext }}
release:
name: Release Installers
@ -124,8 +99,11 @@ jobs:
if: always() && startsWith(github.ref, 'refs/tags/') && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3.0
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v3.2.0
- name: Set Release Version
id: get_version
@ -133,35 +111,52 @@ jobs:
echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Download Installers
if: needs.build-installer.result == 'success'
uses: actions/download-artifact@v4
if: env.WORKFLOW_CONCLUSION == 'success'
uses: actions/download-artifact@v3
- name: Get Changelog
id: get_changelog
run: |
CHANGELOG="$( sed -n '/^## /{p; :loop n; p; /^## /q; b loop}' CHANGELOG.md \
| sed '$d' | sed '$d' | sed '$d' )"
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "CHANGELOG<<$EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "$EOF" >> $GITHUB_OUTPUT
echo "CHANGELOG=${CHANGELOG}" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
uses: actions/create-release@v1
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GHACTIONS_TOKEN }}
with:
tag_name: ${{ steps.get_version.outputs.RELEASE_VERSION }}
name: Tautulli ${{ steps.get_version.outputs.RELEASE_VERSION }}
release_name: Tautulli ${{ steps.get_version.outputs.RELEASE_VERSION }}
body: |
## Changelog
##${{ steps.get_changelog.outputs.CHANGELOG }}
draft: false
prerelease: ${{ endsWith(steps.get_version.outputs.RELEASE_VERSION, '-beta') }}
files: |
Tautulli-windows-installer/Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
Tautulli-macos-installer/Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-universal.pkg
- name: Upload Windows Installer
uses: actions/upload-release-asset@v1
if: env.WORKFLOW_CONCLUSION == 'success'
env:
GITHUB_TOKEN: ${{ secrets.GHACTIONS_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: Tautulli-windows-installer/Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
asset_name: Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
asset_content_type: application/vnd.microsoft.portable-executable
- name: Upload MacOS Installer
uses: actions/upload-release-asset@v1
if: env.WORKFLOW_CONCLUSION == 'success'
env:
GITHUB_TOKEN: ${{ secrets.GHACTIONS_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: Tautulli-macos-installer/Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.pkg
asset_name: Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.pkg
asset_content_type: application/vnd.apple.installer+xml
discord:
name: Discord Notification
@ -169,10 +164,23 @@ jobs:
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3.0
- 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
uses: sarisia/actions-status-discord@v1
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ needs.build-installer.result == 'success' && 'success' || contains(needs.*.result, 'failure') && 'failure' || 'cancelled' }}
status: ${{ steps.status.outputs.status }}
title: ${{ github.workflow }}
nofail: true

View file

@ -20,7 +20,7 @@ jobs:
- armhf
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Prepare
id: prepare
@ -35,22 +35,22 @@ jobs:
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v2
- name: Build Snap Package
uses: diddlesnaps/snapcraft-multiarch-action@master
uses: diddlesnaps/snapcraft-multiarch-action@v1
id: build
with:
architecture: ${{ matrix.architecture }}
- name: Upload Snap Package
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: Tautulli-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@master
uses: diddlesnaps/snapcraft-review-tools-action@v1
with:
snap: ${{ steps.build.outputs.snap }}
@ -69,10 +69,23 @@ jobs:
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3.0
- 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
uses: sarisia/actions-status-discord@v1
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ needs.build-snap.result == 'success' && 'success' || contains(needs.*.result, 'failure') && 'failure' || 'cancelled' }}
status: ${{ steps.status.outputs.status }}
title: ${{ github.workflow }}
nofail: true

View file

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Comment on Pull Request
uses: mshick/add-pr-comment@v2
@ -18,6 +18,7 @@ jobs:
with:
message: Pull requests must be made to the `nightly` branch. Thanks.
repo-token: ${{ secrets.GITHUB_TOKEN }}
repo-token-user-login: 'github-actions[bot]'
- name: Fail Workflow
if: github.base_ref != 'nightly'

View file

@ -9,13 +9,8 @@ jobs:
winget:
name: Submit Winget Package
runs-on: windows-latest
if: ${{ !github.event.release.prerelease }}
if: !github.event.release.prerelease
steps:
- name: Sync Winget Fork
run: gh repo sync ${{ secrets.WINGET_USERNAME }}/winget-pkgs -b master
env:
GH_TOKEN: ${{ secrets.WINGET_TOKEN }}
- name: Submit package to Windows Package Manager Community Repository
run: |
$wingetPackage = "Tautulli.Tautulli"
@ -28,17 +23,3 @@ jobs:
# getting latest wingetcreate file
iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
.\wingetcreate.exe update $wingetPackage -s -v $version -u $installerUrl -t $gitToken
virus-total:
name: VirusTotal Scan
runs-on: ubuntu-latest
steps:
- name: Upload to VirusTotal
uses: crazy-max/ghaction-virustotal@v4
with:
vt_api_key: ${{ secrets.VT_API_KEY }}
github_token: ${{ secrets.GHACTIONS_TOKEN }}
update_release_body: true
files: |
.exe$
.pkg$

3
.gitignore vendored
View file

@ -53,9 +53,6 @@ Thumbs.db
#Ignore files generated by PyCharm
*.idea/*
#Ignore files generated by VSCode
*.vscode/*
#Ignore files generated by vi
*.swp

View file

@ -1,277 +1,6 @@
# Changelog
## v2.15.3 (2025-08-03)
* Exporter:
* New: Added hearingImpaired for subtitles and visualImpaired for audio attributes to exporter fields.
* Graphs:
* Fix: Remove duplicate "Total" entry in graph tooltips. (Thanks @zdimension) (#2534)
* UI:
* Fix: Failing to retrieve collections / playlists with over 1000 items.
* Fix: Scrollbar not showing on macosx and webkit browsers. (#2221)
* Fix: Incorrect rounding of minutes in global stats play duration.
* Fix: Disable browser autocomplete for notification agent and newsletter agent configurations. (#2557)
* API:
* New: Added ability to return svg files using pms_image_proxy API command.
* Other:
* New: Added ability to set config values using environment variables. (Thanks @komuw) (#2309, #2543)
## v2.15.2 (2025-04-12)
* Activity:
* New: Added link to library by clicking media type icon.
* New: Added stream count to tab title on homepage. (#2517)
* History:
* Fix: Check stream watched status before stream stopped status. (#2506)
* Notifications:
* Fix: ntfy notifications failing to send if provider link is blank.
* Fix: Check Pushover notification attachment is under 5MB limit. (#2396)
* Fix: Track URLs redirecting to the correct media page. (#2513)
* New: Added audio profile notification parameters.
* New: Added PATCH method for Webhook notifications.
* Graphs:
* New: Added Total line to daily streams graph. (Thanks @zdimension) (#2497)
* UI:
* Fix: Do not redirect API requests to the login page. (#2490)
* Change: Swap source and stream columns in stream info modal.
* Other:
* Fix: Various typos. (Thanks @luzpaz) (#2520)
* Fix: CherryPy CORS response header not being set correctly. (#2279)
## v2.15.1 (2025-01-11)
* Activity:
* Fix: Detection of HDR transcodes. (Thanks @cdecker08) (#2412, #2466)
* Newsletters:
* Fix: Disable basic authentication for /newsletter and /image endpoints. (#2472)
* Exporter:
* New: Added logos to season and episode exports.
* Other:
* Fix: Docker container https health check.
## v2.15.0 (2024-11-24)
* Notes:
* Support for Python 3.8 has been dropped. The minimum Python version is now 3.9.
* Notifications:
* New: Allow Telegram blockquote and tg-emoji HTML tags. (Thanks @MythodeaLoL) (#2427)
* New: Added Plex slug and Plex Watch URL notification parameters. (#2420)
* Change: Update OneSignal API calls to use the new API endpoint for Tautulli Remote App notifications.
* Newsletters:
* Fix: Dumping custom dates in raw newsletter json.
* History:
* Fix: Unable to fix match for artists. (#2429)
* Exporter:
* New: Added movie and episode hasVoiceActivity attribute to exporter fields.
* New: Added subtitle canAutoSync attribute to exporter fields.
* New: Added logos to the exporter fields.
* UI:
* New: Add friendly name to the top bar of config modals. (Thanks @peagravel) (#2432)
* API:
* New: Added plex slugs to metadata in the get_metadata API command.
* Other:
* Fix: Tautulli failing to start with Python 3.13. (#2426)
## v2.14.6 (2024-10-12)
* Newsletters:
* Fix: Allow formatting newsletter date parameters.
* Change: Support apscheduler compatible cron expressions.
* UI:
* Fix: Round runtime before converting to human duration.
* Fix: Make recently added/watched rows touch scrollable.
* Other:
* Fix: Auto-updater not running.
## v2.14.5 (2024-09-20)
* Activity:
* Fix: Display of 2k resolution on activity card.
* Notifications:
* Fix: ntfy notifications with special characters failing to send.
* Other:
* Fix: Memory leak with database closing. (#2404)
## v2.14.4 (2024-08-10)
* Notifications:
* Fix: Update Slack notification info card.
* New: Added ntfy notification agent. (Thanks @nwithan8) (#2356, #2000)
* UI:
* Fix: macOS platform capitalization.
* Other:
* Fix: Remove deprecated getdefaultlocale. (Thanks @teodorstelian) (#2364, #2345)
## v2.14.3 (2024-06-19)
* Graphs:
* Fix: History table not loading when clicking on the graphs in some instances.
* UI:
* Fix: Scheduled tasks table not loading when certain tasks are disabled.
* Removed: Unnecessary Remote Server checkbox from the settings page.
* Other:
* Fix: Webserver not restarting after the setup wizard.
* Fix: Workaround webserver crashing in some instances.
## v2.14.2 (2024-05-18)
* History:
* Fix: Live TV activity not logging to history.
* Fix: Incorrect grouping of live TV history.
* Notifications:
* Fix: Pushover configuration settings refreshing after entering a token.
* Fix: Plex remote access down notifications not triggering.
* Fix: Deleting all images from Cloudinary only deleting 1000 images.
* New: Added platform version and product version notification parameters. (#2244)
* New: Added LAN streams and WAN streams notification parameters. (#2276)
* New: Added Dolby Vision notification parameters. (#2240)
* New: Added live TV channel notification parameters.
* Change: Improved Tautulli Remote App notification encryption method.
* Note: Requires Tautulli Remote App version 3.2.4.
* Exporter:
* New: Added slug attribute to exporter fields.
* New: Added track genres to exporter fields.
* New: Added playlist source URI to exporter fields.
* New: Added artProvider and thumbProvider to exporter fields.
* UI:
* Fix: Mask deleted usernames in the logs.
* Fix: Live TV watch stats not showing on the media info page.
* Fix: Users without access to Plex server not showing as inactive.
* Removed: Deprecated synced item pages.
* Removed: Anonymous redirect settings. Links now use browser no-referrer policy instead.
* API:
* New: Added Dolby Vision info to the get_metadata API command.
* New: Added before and after parameters to the get_home_stats API command. (#2231)
* Packages:
* New: Universal binary for macOS for Apple silicon.
* New: Bump Snap package to core22.
* Other:
* Change: Login cookie expires changed to max-age.
* Change: Improved key generation for login password. It is recommended to reenter your HTTP Password in the settings after upgrading.
* Removed: Python 2 compatibility code. (#2098, #2226) (Thanks @zdimension)
## v2.13.4 (2023-12-07)
* UI:
* Fix: Tautulli configuration settings page not loading when system language is None.
* Fix: Login cookie expiring too quickly.
## v2.13.3 (2023-12-03)
* Notifications:
* New: Added duration_time notification parameter.
* New: Added file_size_bytes notification parameter.
* New: Added time formats notification text modifiers.
* New: Added support for thetvdb_url for movies.
* UI:
* Fix: Activity card overflowing due to screen scaling. (#2033)
* Fix: Stream duration on activity card not being updated on track changes in some cases. (#2206)
## v2.13.2 (2023-10-26)
* History:
* New: Added quarter values icons for history watch status. (#2179, #2156) (Thanks @herby2212)
* Graphs:
* New: Added concurrent streams per day graph. (#2046) (Thanks @herby2212)
* Exporter:
* New: Added metadata directory to exporter fields.
* Removed: Banner exporter fields for tv shows.
* UI:
* New: Added last triggered time to notification agents and newsletter agent lists.
* Other:
* New: Added X-Plex-Language header override to config file.
## v2.13.1 (2023-08-25)
* Notes:
* Support for Python 3.7 has been dropped. The minimum Python version is now 3.8.
* Other:
* Fix: Tautulli failing to start on some systems.
## v2.13.0 (2023-08-25)
* Notes:
* Support for Python 3.7 has been dropped. The minimum Python version is now 3.8.
* Notifications:
* Fix: Improved watched notification trigger description. (#2104)
* New: Added notification image option for iOS Tautulli Remote app.
* Exporter:
* New: Added track chapter export fields.
* New: Added on-demand subtitle export fields.
## v2.12.5 (2023-07-13)
* Activity:
* New: Added d3d11va to list of hardware decoders.
* History:
* Fix: Incorrect grouping of play history.
* New: Added button in settings to regroup play history.
* Notifications:
* Fix: Incorrect concurrent streams notifications by IP addresss for IPv6 addresses (#2096) (Thanks @pooley182)
* UI:
* Fix: Occasional UI crashing on Python 3.11.
* New: Added multiselect user filters to History and Graphs pages. (#2090) (Thanks @zdimension)
* API:
* New: Added regroup_history API command.
* Change: Updated graph API commands to accept a comma separated list of user IDs.
## v2.12.4 (2023-05-23)
* History:
* Fix: Set view offset equal to duration if a stream is stopped within the last 10 sec.
* Other:
* Fix: Database import may fail for some older databases.
* Fix: Double-quoted strings for newer versions of SQLite. (#2015, #2057)
* API:
* Change: Return the ID for async API calls (export_metadata, notify, notify_newsletter).
## v2.12.3 (2023-04-14)
* Activity:
* Fix: Incorrect subtitle decision shown when subtitles are transcoded.
* History:
* Fix: Incorrect order when sorting by the duration column in the history tables.
* Notifications:
* Fix: Logging error when running scripts that use PlexAPI.
* UI:
* Fix: Calculate file sizes setting causing the media info table to fail to load.
* Fix: Incorrect artwork and thumbnail shown for Live TV on the Most Active Libraries statistics card.
* API:
* Change: Renamed duration to play_duration in the get_history API response. (Note: duration kept for backwards compatibility.)
## v2.12.2 (2023-03-16)
* Other:
* Fix: Tautulli not starting on FreeBSD jails.
## v2.12.1 (2023-03-14)
* Activity:
* Fix: Stop checking for deprecated sync items sessions.
* Change: Do not show audio language on activity cards for music.
* Other:
* Fix: Tautulli not starting on macOS.
## v2.12.0 (2023-03-13)
## v2.12.0-beta (2023-03-03)
* Notifications:
* New: Added support for Telegram group topics. (#1980)
@ -284,21 +13,18 @@
* Change: Trigger watched notifications based on the video watched completion behaviour setting.
* Exporter:
* Fix: Unable to run exporter when using the Snap package. (#2007)
* New: Added credits marker, and audio/subtitle settings to export fields.
* UI:
* Fix: Incorrect styling and missing content for collection media info pages.
* New: Added edition details field on movie media info pages. (#1957) (Thanks @herby2212)
* New: Added setting to change the video watched completion behaviour.
* New: Added watch time and user statistics to collection and playlist media info pages. (#1982, #2012) (Thanks @herby2212)
* New: Added history table to collection and playlist media info pages.
* New: Dynamically change watched status in the UI based on video watched completion behaviour setting.
* New: Added hidden setting to override server name.
* Fix: Incorrect styling and missing content for collection media info pages.
* Change: Move track artist to a details field instead of in the title on track media info pages.
* API:
* New: Added section_id and user_id parameters to get_home_stats API command. (#1944)
* New: Added marker info to get_metadata API command results.
* New: Added media_type parameter to get_item_watch_time_stats and get_item_user_stats API commands. (#1982) (Thanks @herby2212)
* New: Added last_refreshed timestamp to get_library_media_info API command response.
* Other:
* Change: Migrate analytics to Google Analytics 4.

View file

@ -9,7 +9,7 @@ All pull requests should be based on the `nightly` branch, to minimize cross mer
### Python Code
#### Compatibility
The code should work with Python 3.8+. Note that Tautulli runs on many different platforms.
The code should work with Python 3.7+. Note that Tautulli runs on many different platforms.
Re-use existing code. Do not hesitate to add logging in your code. You can the logger module `plexpy.logger.*` for this. Web requests are invoked via `plexpy.request.*` and derived ones. Use these methods to automatically add proper and meaningful error handling.

View file

@ -25,4 +25,4 @@ CMD [ "python", "Tautulli.py", "--datadir", "/config" ]
ENTRYPOINT [ "./start.sh" ]
EXPOSE 8181
HEALTHCHECK --start-period=90s CMD curl -ILfks https://localhost:8181/status > /dev/null || curl -ILfs http://localhost:8181/status > /dev/null || exit 1
HEALTHCHECK --start-period=90s CMD curl -ILfSs http://localhost:8181/status > /dev/null || curl -ILfkSs https://localhost:8181/status > /dev/null || exit 1

View file

@ -36,7 +36,7 @@ and [PlexWatchWeb](https://github.com/ecleese/plexWatchWeb).
[![Docker Stars][badge-docker-stars]][DockerHub]
[![Downloads][badge-downloads]][Releases Latest]
[badge-python]: https://img.shields.io/badge/python->=3.9-blue?style=flat-square
[badge-python]: https://img.shields.io/badge/python->=3.7-blue?style=flat-square
[badge-docker-pulls]: https://img.shields.io/docker/pulls/tautulli/tautulli?style=flat-square
[badge-docker-stars]: https://img.shields.io/docker/stars/tautulli/tautulli?style=flat-square
[badge-downloads]: https://img.shields.io/github/downloads/Tautulli/Tautulli/total?style=flat-square
@ -129,7 +129,7 @@ This is free software under the GPL v3 open source license. Feel free to do with
but any modification must be open sourced. A copy of the license is included.
This software includes Highsoft software libraries which you may freely distribute for
non-commercial use. Commercial users must licence this software, for more information visit
non-commercial use. Commerical users must licence this software, for more information visit
https://shop.highsoft.com/faq/non-commercial#non-commercial-redistribution.

View file

@ -23,18 +23,18 @@ import sys
# Ensure lib added to path, before any other imports
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib'))
from future.builtins import str
import appdirs
import argparse
import datetime
import locale
import platformdirs
import pytz
import signal
import shutil
import time
import threading
import tzlocal
import ctypes
import plexpy
from plexpy import common, config, database, helpers, logger, webstart
@ -70,26 +70,8 @@ def main():
plexpy.SYS_ENCODING = None
try:
# Attempt to get the system's locale settings
language_code, encoding = locale.getlocale()
# Special handling for Windows platform
if sys.platform == 'win32':
# Get the user's current language settings on Windows
windll = ctypes.windll.kernel32
lang_id = windll.GetUserDefaultLCID()
# Map Windows language ID to locale identifier
language_code = locale.windows_locale.get(lang_id, '')
# Get the preferred encoding
encoding = locale.getpreferredencoding()
# Assign values to application-specific variable
plexpy.SYS_LANGUAGE = language_code
plexpy.SYS_ENCODING = encoding
locale.setlocale(locale.LC_ALL, "")
plexpy.SYS_LANGUAGE, plexpy.SYS_ENCODING = locale.getdefaultlocale()
except (locale.Error, IOError):
pass
@ -129,7 +111,7 @@ def main():
if args.quiet:
plexpy.QUIET = True
# Do an initial setup of the logger.
# Do an intial setup of the logger.
# Require verbose for pre-initilization to see critical errors
logger.initLogger(console=not plexpy.QUIET, log_dir=False, verbose=True)
@ -204,7 +186,7 @@ def main():
if args.datadir:
plexpy.DATA_DIR = args.datadir
elif plexpy.FROZEN:
plexpy.DATA_DIR = platformdirs.user_data_dir("Tautulli", False)
plexpy.DATA_DIR = appdirs.user_data_dir("Tautulli", False)
else:
plexpy.DATA_DIR = plexpy.PROG_DIR

View file

@ -13,7 +13,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
<meta name="referrer" content="no-referrer">
<link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet">
<link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" />
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
@ -124,6 +123,11 @@
% else:
<li><a href="graphs">Graphs</a></li>
% 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":
<li class="dropdown active">
% else:
@ -234,7 +238,7 @@ ${next.modalIncludes()}
<li><a href="#patreon-donation" role="tab" data-toggle="tab">Patreon</a></li>
<li><a href="#stripe-donation" role="tab" data-toggle="tab">Stripe</a></li>
<li><a href="#paypal-donation" role="tab" data-toggle="tab">PayPal</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab" id="crypto-donation-tab">Crypto</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab">Crypto</a></li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="github-donation" style="text-align: center">
@ -283,16 +287,7 @@ ${next.modalIncludes()}
</div>
<div role="tabpanel" class="tab-pane" id="crypto-donation" style="text-align: center">
<p>
Select a cryptocurrency.
</p>
<select class="form-control" id="crypto-select"></select>
<div id="crypto-qrcode"></div>
<div id="crypto-address" class="form-group">
<label>Address:</label>
<span class="inline-pre" id="crypto-address-value"></span>
</div>
<p>
Or click the button below to continue to Coinbase.
Click the button below to continue to Coinbase.
</p>
<a href="${anon_url('https://commerce.coinbase.com/checkout/8a9fa08c-8a38-409e-9220-868124c4ba0c')}" target="_blank" rel="noreferrer" class="donate-with-crypto">
<span>Donate with Crypto</span>
@ -340,7 +335,6 @@ ${next.modalIncludes()}
<script src="${http_root}js/blurhash_pure_js_port.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/ajaxNotifications.js"></script>
<script src="${http_root}js/kjua.min.js"></script>
<script>
% if _session['user_group'] == 'admin':
$('body').on('click', '#updateDismiss', function() {
@ -414,42 +408,6 @@ ${next.modalIncludes()}
checkUpdate(function () { $('#nav-update').html('<i class="fa fa-fw fa-arrow-alt-circle-up"></i> Check for Updates'); });
});
$('#crypto-donation-tab').one('shown.bs.tab', function (e) {
$.ajax({
url: 'https://tautulli.com/donate/crypto-addresses.json',
type: 'GET',
dataType: 'json',
cache: false,
async: true,
success: function (data) {
$('#crypto-select').empty().append('<option selected disabled>Select Cryptocurrency</option>');
$.each(data, function (index, crypto) {
$('<option/>', {
text: crypto.name + ' (' + crypto.symbol + ')',
value: crypto.address
}).appendTo('#crypto-select');
});
},
error: function () {
$('#crypto-select').empty().append('<option selected disabled>Error: Unable to load addresses</option>');
}
});
});
$('#crypto-select').change(function() {
var address = $(this).val();
$('#crypto-qrcode').empty().kjua({
text: address,
render: 'canvas',
ecLevel: 'H',
size: 256,
fill: '#000',
back: '#eee'
}).show();
$('#crypto-address-value').text(address);
$('#crypto-address').show();
})
% endif
$('.dropdown-toggle').click(function (e) {

View file

@ -11,7 +11,6 @@ DOCUMENTATION :: END
<%!
import os
import sqlite3
import sys
import plexpy
from plexpy import common, logger
@ -72,18 +71,10 @@ DOCUMENTATION :: END
<td>System Timezone:</td>
<td>${str(plexpy.SYS_TIMEZONE)} (${'UTC{}'.format(plexpy.SYS_UTC_OFFSET)})
</tr>
<tr>
<td>System Language:</td>
<td>${plexpy.SYS_LANGUAGE}${' (override {})'.format(plexpy.CONFIG.PMS_LANGUAGE) if plexpy.CONFIG.PMS_LANGUAGE else ''}</td>
</tr>
<tr>
<td>Python Version:</td>
<td>${sys.version}</td>
</tr>
<tr>
<td>SQLite Version:</td>
<td>${sqlite3.sqlite_version}</td>
</tr>
<tr>
<td class="top-line">Resources:</td>
<td class="top-line">

File diff suppressed because one or more lines are too long

View file

@ -338,20 +338,20 @@ object {
}
.btn-dark:focus,
.btn-dark.focus {
color: #d7d7d7;
background-color: #3B3B3B;
color: #d7d7d7;
background-color: #3B3B3B;
}
.btn-dark:hover {
color: #eee;
background-color: #333;
border-color: #444;
color: #eee;
background-color: #333;
border-color: #444;
}
.btn-dark:active,
.btn-dark.active,
.open > .dropdown-toggle.btn-dark {
color: #eee;
background-color: #333;
border-color: #444;
color: #eee;
background-color: #333;
border-color: #444;
}
.btn-dark:active:hover,
.btn-dark.active:hover,
@ -362,13 +362,13 @@ object {
.btn-dark:active.focus,
.btn-dark.active.focus,
.open > .dropdown-toggle.btn-dark.focus {
color: #eee;
background-color: #333;
color: #eee;
background-color: #333;
}
.btn-dark:active,
.btn-dark.active,
.open > .dropdown-toggle.btn-dark {
background-image: none;
background-image: none;
}
.btn-dark.disabled,
.btn-dark[disabled],
@ -388,8 +388,8 @@ fieldset[disabled] .btn-dark:active,
.btn-dark.disabled.active,
.btn-dark[disabled].active,
fieldset[disabled] .btn-dark.active {
background-color: #333;
color: #aaa;
background-color: #333;
color: #aaa;
}
.btn-dark.inactive:hover {
color: #d7d7d7;
@ -398,30 +398,30 @@ fieldset[disabled] .btn-dark.active {
cursor: default;
}
.btn-dark .badge {
color: #e5e5e5;
background-color: #3B3B3B;
color: #e5e5e5;
background-color: #3B3B3B;
}
.btn-bright {
color: #eee;
background-color: #cc7b19;
box-shadow: inset 0 1px 0 #e7993b;
color: #eee;
background-color: #cc7b19;
box-shadow: inset 0 1px 0 #e7993b;
}
.btn-bright:focus,
.btn-bright.focus {
color: #eee;
background-color: #eb8600;
color: #eee;
background-color: #eb8600;
}
.btn-bright:hover {
color: #eee;
background-color: #e59029;
box-shadow: inset 0 1px 0 #ebac60;
color: #eee;
background-color: #e59029;
box-shadow: inset 0 1px 0 #ebac60;
}
.btn-bright:active,
.btn-bright.active,
.open > .dropdown-toggle.btn-bright {
color: #eee;
background-color: #cc7b19;
box-shadow: inset 0 1px 0 #e7993b;
color: #eee;
background-color: #cc7b19;
box-shadow: inset 0 1px 0 #e7993b;
}
.btn-bright:active:hover,
.btn-bright.active:hover,
@ -432,14 +432,14 @@ fieldset[disabled] .btn-dark.active {
.btn-bright:active.focus,
.btn-bright.active.focus,
.open > .dropdown-toggle.btn-bright.focus {
color: #eee;
background-color: #cc7b19;
box-shadow: inset 0 1px 0 #e7993b;
color: #eee;
background-color: #cc7b19;
box-shadow: inset 0 1px 0 #e7993b;
}
.btn-bright:active,
.btn-bright.active,
.open > .dropdown-toggle.btn-bright {
background-image: none;
background-image: none;
}
.btn-bright.disabled,
.btn-bright[disabled],
@ -459,13 +459,13 @@ fieldset[disabled] .btn-bright:active,
.btn-bright.disabled.active,
.btn-bright[disabled].active,
fieldset[disabled] .btn-bright.active {
background-color: #cc7b19;
border-color: #b56d16;
background-color: #cc7b19;
border-color: #b56d16;
}
.btn-bright .badge {
color: #eee;
background-color: #cc7b19;
box-shadow: inset 0 1px 0 #e7993b;
color: #eee;
background-color: #cc7b19;
box-shadow: inset 0 1px 0 #e7993b;
}
.btn-danger.btn-edit {
color: #d7d7d7;
@ -479,14 +479,14 @@ fieldset[disabled] .btn-bright.active {
border-color: #ac2925;
}
.btn-danger.btn-edit.active {
color: #eee;
background-color: #c9302c;
border-color: #ac2925;
color: #eee;
background-color: #c9302c;
border-color: #ac2925;
}
.btn-danger.btn-edit.active:hover {
color: #eee;
background-color: #ac2925;
border-color: #761c19;
color: #eee;
background-color: #ac2925;
border-color: #761c19;
}
.btn-group select {
margin-top: 0;
@ -667,12 +667,12 @@ textarea.form-control:focus {
white-space: nowrap;
vertical-align: middle;
-ms-touch-action: manipulation;
touch-action: manipulation;
touch-action: manipulation;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-image: none;
background-color: #3B3B3B;
color: #e5e5e5;
@ -690,10 +690,10 @@ textarea.form-control:focus {
}
.btn-filter.active,
.btn-filter.active.focus {
background-color: #b7800a !important;
background-color: #b7800a !important;
}
.btn-filter.active:hover {
background-color: #896007 !important;
background-color: #896007 !important;
}
.form-control-feedback {
color: #E5A00D;
@ -965,7 +965,7 @@ a .users-poster-face:hover {
font-size: 10px;
text-align: right;
text-transform: uppercase;
line-height: 10px;
line-height: 14px;
-webkit-flex-shrink: 0;
flex-shrink: 0;
}
@ -1281,7 +1281,7 @@ a .dashboard-activity-metadata-user-thumb:hover {
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
z-index: 1;
-webkit-border-radius: 50%;
-webkit-border-radius: 50%;
-moz-border-radius: 50%;
border-radius: 350%;
overflow: hidden;
@ -1478,8 +1478,7 @@ a:hover .dashboard-stats-square {
text-align: center;
position: relative;
z-index: 0;
overflow: auto;
scrollbar-width: none;
overflow: hidden;
}
.dashboard-recent-media {
width: 100%;
@ -2204,8 +2203,8 @@ span.settings-warning {
padding-left: 10px;
}
#menu_link_show_advanced_settings.active {
color: #eee;
background-color: #cc7b19;
color: #eee;
background-color: #cc7b19;
}
#configUpdate .form-group,
#configUpdate .checkbox{
@ -2855,30 +2854,6 @@ a .home-platforms-list-cover-face:hover
overflow: hidden;
max-width: 350px;
}
.circle {
width: 1.55rem;
height: 1.55rem;
border-radius: 50%;
border: 0.2rem solid #eeeeee;
}
.circle-quarter {
background-image:
linear-gradient(00deg, #2b2b2b 50%, transparent 50%),
linear-gradient(270deg, #eeeeee 50%, transparent 50%);
}
.circle-half {
background-image:
linear-gradient(90deg, #2b2b2b 50%, transparent 50%),
linear-gradient(-90deg, #eeeeee 50%, transparent 50%);
}
.circle-three-quarter {
background-image:
linear-gradient(180deg, transparent 50%, #eeeeee 50%),
linear-gradient(-90deg, #eeeeee 50%, transparent 50%);
}
.circle-full {
background: #eeeeee;
}
#graph-tabs {
padding-bottom: 10px;
float: none;
@ -2939,7 +2914,7 @@ a .home-platforms-list-cover-face:hover
margin-bottom: -20px;
width: 100%;
max-width: 1750px;
display: flow-root;
overflow: hidden;
}
.table-card-back td {
font-size: 12px;
@ -3009,15 +2984,14 @@ a .home-platforms-list-cover-face:hover
max-width: 900px;
}
.stacked-configs > li > span {
display: inline-block;
width: inherit;
display: block;
padding: 8px 20px 8px 15px;
color: #eee;
border-left: 2px solid #444;
border-top: 1px solid #2d2d2d;
-webkit-transition: all 0.3s ease;
-o-transition: all 0.3s ease;
transition: all 0.3s ease;
-o-transition: all 0.3s ease;
transition: all 0.3s ease;
}
.stacked-configs > li > span:hover,
.stacked-configs > li > span:focus {
@ -4325,10 +4299,6 @@ a:hover .overlay-refresh-image:hover {
.stream-info tr:nth-child(even) td {
background-color: rgba(255,255,255,0.010);
}
.stream-info td:nth-child(3),
.stream-info th:nth-child(3) {
width: 25px;
}
.number-input {
margin: 0 !important;
width: 55px !important;
@ -4575,32 +4545,12 @@ a.donate-with-crypto::after {
top: 0;
left: 0;
}
#crypto-select {
width: 280px;
margin: 15px auto;
}
#crypto-qrcode {
width: 258px;
padding: 0;
margin: 15px auto;
line-height: 0;
text-align: center;
background-color: #eee;
border: 1px solid #ccc;
border-radius: 4px;
display: none;
}
#crypto-address {
margin: 15px auto;
text-align: center;
display: none;
}
#api_qr_code {
width: 100%;
padding: 0;
margin: 0 0 10px;
line-height: 0;
line-height: 1;
text-align: center;
background-color: #eee;
border: 1px solid #ccc;

View file

@ -74,7 +74,6 @@ DOCUMENTATION :: END
parent_href = page('info', data['parent_rating_key'])
grandparent_href = page('info', data['grandparent_rating_key'])
user_href = page('user', data['user_id']) if data['user_id'] else '#'
library_href = page('library', data['section_id']) if data['section_id'] else '#'
season = short_season(data['parent_title'])
%>
<div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}"
@ -266,15 +265,12 @@ DOCUMENTATION :: END
<div class="sub-heading">Audio</div>
<div class="sub-value" id="audio_decision-${sk}">
% if data['stream_audio_decision']:
<%
audio_language = (data['audio_language'] or 'Unknown') + ' - ' if data['media_type'] != 'track' else ''
%>
% if data['stream_audio_decision'] == 'transcode':
Transcode (${audio_language}${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} <i class="fa fa-long-arrow-right"></i> ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
Transcode (${data['audio_language'] or 'Unknown'} - ${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} <i class="fa fa-long-arrow-right"></i> ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% elif data['stream_audio_decision'] == 'copy':
Direct Stream (${audio_language}${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
Direct Stream (${data['audio_language'] or 'Unknown'} - ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% else:
Direct Play (${audio_language}${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
Direct Play (${data['audio_language'] or 'Unknown'} - ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% endif
% endif
</div>
@ -370,7 +366,7 @@ DOCUMENTATION :: END
% if data['media_type'] != 'photo':
<div class="dashboard-activity-info-time">
% 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_title'] or (data['channel_vcn'] + ' ' + data['channel_call_sign'])}</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_call_sign']} ${data['channel_identifier']}</span>
% elif data['view_offset']:
ETA:
<span id="stream-eta-${sk}">
@ -464,27 +460,21 @@ DOCUMENTATION :: END
<div class="dashboard-activity-metadata-subtitle-container">
% if data['live']:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Live TV">
<a href="${library_href}">
<i class="fa fa-fw fa-broadcast-tower"></i>
</a>&nbsp;
<i class="fa fa-fw fa-broadcast-tower"></i>&nbsp;
</div>
% elif data['channel_stream'] == 0:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="${data['media_type'].capitalize()}">
<a href="${library_href}">
% if data['media_type'] == 'movie':
<i class="fa fa-fw fa-film"></i>
% elif data['media_type'] == 'episode':
<i class="fa fa-fw fa-television"></i>
% elif data['media_type'] == 'track':
<i class="fa fa-fw fa-music"></i>
% elif data['media_type'] == 'photo':
<i class="fa fa-fw fa-picture-o"></i>
% elif data['media_type'] == 'clip':
<i class="fa fa-fw fa-video-camera"></i>
% else:
<i class="fa fa-fw fa-question-circle"></i>
% endif
</a>&nbsp;
% if data['media_type'] == 'movie':
<i class="fa fa-fw fa-film"></i>&nbsp;
% elif data['media_type'] == 'episode':
<i class="fa fa-fw fa-television"></i>&nbsp;
% elif data['media_type'] == 'track':
<i class="fa fa-fw fa-music"></i>&nbsp;
% elif data['media_type'] == 'photo':
<i class="fa fa-fw fa-picture-o"></i>&nbsp;
% elif data['media_type'] == 'clip':
<i class="fa fa-fw fa-video-camera"></i>&nbsp;
% endif
</div>
% else:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Channel">

View file

@ -20,7 +20,6 @@ DOCUMENTATION :: END
export = exporter.Export()
thumb_media_types = ', '.join([export.PLURAL_MEDIA_TYPES[k] for k, v in export.MEDIA_TYPES.items() if v[0]])
art_media_types = ', '.join([export.PLURAL_MEDIA_TYPES[k] for k, v in export.MEDIA_TYPES.items() if v[1]])
logo_media_types = ', '.join([export.PLURAL_MEDIA_TYPES[k] for k, v in export.MEDIA_TYPES.items() if v[2]])
%>
<div class="modal-dialog" role="document">
<div class="modal-content">
@ -145,22 +144,6 @@ DOCUMENTATION :: END
Select the level to export background artwork image files.<br>Note: Only applies to ${art_media_types}.
</p>
</div>
<div class="form-group">
<label for="export_logo_level">Logo Image Export Level</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="export_logo_level" name="export_logo_level">
<option value="0" selected>Level 0 - None / Custom</option>
<option value="1">Level 1 - Uploaded and Selected Logos Only</option>
<option value="2">Level 2 - Selected and Locked Logos Only</option>
<option value="9">Level 9 - All Selected Logos</option>
</select>
</div>
</div>
<p class="help-block">
Select the level to export logo image files.<br>Note: Only applies to ${logo_media_types}.
</p>
</div>
<p class="help-block">
Warning: Exporting images may take a long time! Images will be saved to a folder alongside the data file.
</p>
@ -248,7 +231,6 @@ DOCUMENTATION :: END
$('#export_media_info_level').prop('disabled', true);
$("#export_thumb_level").prop('disabled', true);
$("#export_art_level").prop('disabled', true);
$("#export_logo_level").prop('disabled', true);
export_custom_metadata_fields.disable();
export_custom_media_info_fields.disable();
} else {
@ -256,7 +238,6 @@ DOCUMENTATION :: END
$('#export_media_info_level').prop('disabled', false);
$("#export_thumb_level").prop('disabled', false);
$("#export_art_level").prop('disabled', false);
$("#export_logo_level").prop('disabled', false);
export_custom_metadata_fields.enable();
export_custom_media_info_fields.enable();
}
@ -271,7 +252,6 @@ DOCUMENTATION :: END
var file_format = $('#export_file_format option:selected').val();
var thumb_level = $("#export_thumb_level option:selected").val();
var art_level = $("#export_art_level option:selected").val();
var logo_level = $("#export_logo_level option:selected").val();
var custom_fields = [
$('#export_custom_metadata_fields').val(),
$('#export_custom_media_info_fields').val()
@ -290,7 +270,6 @@ DOCUMENTATION :: END
file_format: file_format,
thumb_level: thumb_level,
art_level: art_level,
logo_level: logo_level,
custom_fields: custom_fields,
export_type: export_type,
individual_files: individual_files

View file

@ -1,7 +1,6 @@
<%inherit file="base.html"/>
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/bootstrap-select.min.css">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.min.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
</%def>
@ -15,7 +14,9 @@
<div class="button-bar">
<div class="btn-group" id="user-selection">
<label>
<select name="graph-user" id="graph-user" multiple>
<select name="graph-user" id="graph-user" class="btn" style="color: inherit;">
<option value="">All Users</option>
<option disabled>&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;</option>
</select>
</label>
</div>
@ -137,20 +138,6 @@
</div>
</div>
</div>
<div class="row" id="concurrent-graph">
<div class="col-md-12">
<h4><i class="fa fa-video-camera"></i> Daily concurrent stream count</span> by stream type <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The total count of concurrent streams of tv, movies, and music by the transcode decision.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="graph_concurrent_streams_by_stream_type">
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...</div>
<br>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h4><i class="fa fa-expand-arrows-alt"></i> <span class="yaxis-text">Play count</span> by source resolution <small>Last <span class="days">30</span> days</small></h4>
@ -238,7 +225,6 @@
</%def>
<%def name="javascriptIncludes()">
<script src="${http_root}js/bootstrap-select.min.js"></script>
<script src="${http_root}js/highcharts.min.js"></script>
<script src="${http_root}js/jquery.dataTables.min.js"></script>
<script src="${http_root}js/dataTables.bootstrap.min.js"></script>
@ -301,10 +287,6 @@
return obj;
}, {});
if (!("Total" in chart_visibility)) {
chart_visibility["Total"] = false;
}
return data_series.map(function(s) {
var obj = Object.assign({}, s);
obj.visible = (chart_visibility[s.name] !== false);
@ -330,9 +312,7 @@
'Live TV': '#19A0D7',
'Direct Play': '#E5A00D',
'Direct Stream': '#FFFFFF',
'Transcode': '#F06464',
'Max. Concurrent Streams': '#96C83C',
'Total': '#96C83C'
'Transcode': '#F06464'
};
var series_colors = [];
$.each(data_series, function(index, series) {
@ -347,7 +327,6 @@
<script src="${http_root}js/graphs/plays_by_platform.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_user.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_stream_type.js${cache_param}"></script>
<script src="${http_root}js/graphs/concurrent_streams_by_stream_type.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_source_resolution.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_stream_resolution.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_platform_by_stream_type.js${cache_param}"></script>
@ -377,10 +356,6 @@
break
}
if (window.location.hash === '#concurrent-graph') {
current_tab = '#tabs-stream';
}
$('#yaxis-' + yaxis).prop('checked', true);
$('#yaxis-' + yaxis).closest('label').addClass('active');
$('#graph-days').val(current_day_range);
@ -389,8 +364,8 @@
//$(current_tab).addClass('active');
$('.days').text(current_day_range);
$('.months').text(current_month_range);
$('.days').html(current_day_range);
$('.months').html(current_month_range);
// Load user ids and names (for the selector)
$.ajax({
@ -398,35 +373,14 @@
type: 'get',
dataType: "json",
success: function (data) {
let select = $('#graph-user');
let by_id = {};
var select = $('#graph-user');
data.sort(function(a, b) {
return a.friendly_name.localeCompare(b.friendly_name);
});
data.forEach(function(item) {
select.append('<option value="' + item.user_id + '">' +
item.friendly_name + '</option>');
by_id[item.user_id] = item.friendly_name;
});
select.selectpicker({
countSelectedText: function(sel, total) {
if (sel === 0 || sel === total) {
return 'All users';
} else if (sel > 1) {
return sel + ' users';
} else {
return select.val().map(function(id) {
return by_id[id];
}).join(', ');
}
},
style: 'btn-dark',
actionsBox: true,
selectedTextFormat: 'count',
noneSelectedText: 'All users'
});
select.selectpicker('render');
select.selectpicker('selectAll');
}
});
@ -565,33 +519,6 @@
}
});
$.ajax({
url: "get_concurrent_streams_by_stream_type",
type: 'get',
data: { time_range: time_range, user_id: selected_user_id },
dataType: "json",
success: function(data) {
var dateArray = [];
$.each(data.categories, function (i, day) {
dateArray.push(moment(day, 'YYYY-MM-DD').valueOf());
// Highlight the weekend
if ((moment(day, 'YYYY-MM-DD').format('ddd') == 'Sat') ||
(moment(day, 'YYYY-MM-DD').format('ddd') == 'Sun')) {
hc_plays_by_day_options.xAxis.plotBands.push({
from: i-0.5,
to: i+0.5,
color: 'rgba(80,80,80,0.3)'
});
}
});
hc_concurrent_streams_by_stream_type_options.yAxis.min = 0;
hc_concurrent_streams_by_stream_type_options.xAxis.categories = dateArray;
hc_concurrent_streams_by_stream_type_options.series = getGraphVisibility(hc_concurrent_streams_by_stream_type_options.chart.renderTo, data.series);
hc_concurrent_streams_by_stream_type_options.colors = getGraphColors(data.series);
var hc_plays_by_stream_type = new Highcharts.Chart(hc_concurrent_streams_by_stream_type_options);
}
});
$.ajax({
url: "get_plays_by_source_resolution",
type: 'get',
@ -648,7 +575,7 @@
}
});
$('#nav-tabs-stream').tab('show');
$('#nav-tabs-2').tab('show');
}
function loadGraphsTab3(time_range, yaxis) {
@ -675,6 +602,11 @@
$('#nav-tabs-total').tab('show');
}
// Set initial state
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
// Tab1 opened
$('#nav-tabs-plays').on('shown.bs.tab', function (e) {
e.preventDefault();
@ -707,7 +639,7 @@
setLocalStorage('graph_days', current_day_range);
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
$('.days').text(current_day_range);
$('.days').html(current_day_range);
});
// Month range changed
@ -717,23 +649,12 @@
current_month_range = $(this).val();
setLocalStorage('graph_months', current_month_range);
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
$('.months').text(current_month_range);
$('.months').html(current_month_range);
});
let graph_user_last_id = undefined;
// User changed
$('#graph-user').on('change', function() {
let val = $(this).val();
if (val.length === 0 || val.length === $(this).children().length) {
selected_user_id = null; // if all users are selected, just send an empty list
} else {
selected_user_id = val.join(",");
}
if (selected_user_id === graph_user_last_id) {
return;
}
graph_user_last_id = selected_user_id;
selected_user_id = $(this).val() || null;
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
@ -760,7 +681,6 @@
if (this.points.length > 1) {
var total = 0;
$.each(this.points, function(i, point) {
if (point.series.name === 'Total') return;
s += '<br/>'+point.series.name+': '+point.y;
total += point.y;
});
@ -787,7 +707,6 @@
if (this.points.length > 1) {
var total = 0;
$.each(this.points, function(i, point) {
if (point.series.name === 'Total') return;
s += '<br/>'+point.series.name+': '+moment.duration(point.y, 'hours').format('D [days] H [hrs] m [mins]');
total += point.y;
});
@ -808,7 +727,6 @@
hc_plays_by_day_options.xAxis.plotBands = [];
hc_plays_by_stream_type_options.xAxis.plotBands = [];
hc_concurrent_streams_by_stream_type_options.xAxis.plotBands = [];
hc_plays_by_day_options.yAxis.labels.formatter = yaxis_format;
hc_plays_by_dayofweek_options.yAxis.labels.formatter = yaxis_format;

View file

@ -1,7 +1,6 @@
<%inherit file="base.html"/>
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/bootstrap-select.min.css">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.min.css">
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
@ -32,7 +31,9 @@
% if _session['user_group'] == 'admin':
<div class="btn-group" id="user-selection">
<label>
<select name="history-user" id="history-user" multiple>
<select name="history-user" id="history-user" class="btn" style="color: inherit;">
<option value="">All Users</option>
<option disabled>&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;</option>
</select>
</label>
</div>
@ -83,7 +84,7 @@
<th align="left" id="started">Started</th>
<th align="left" id="paused_counter">Paused</th>
<th align="left" id="stopped">Stopped</th>
<th align="left" id="play_duration">Duration</th>
<th align="left" id="duration">Duration</th>
<th align="left" id="percent_complete"></th>
</tr>
</thead>
@ -120,7 +121,6 @@
</%def>
<%def name="javascriptIncludes()">
<script src="${http_root}js/bootstrap-select.min.js"></script>
<script src="${http_root}js/jquery.dataTables.min.js"></script>
<script src="${http_root}js/dataTables.colVis.js"></script>
<script src="${http_root}js/dataTables.bootstrap.min.js"></script>
@ -134,40 +134,17 @@
type: 'GET',
dataType: 'json',
success: function (data) {
let select = $('#history-user');
let by_id = {};
var select = $('#history-user');
data.sort(function (a, b) {
return a.friendly_name.localeCompare(b.friendly_name);
});
data.forEach(function (item) {
select.append('<option value="' + item.user_id + '">' +
item.friendly_name + '</option>');
by_id[item.user_id] = item.friendly_name;
});
select.selectpicker({
countSelectedText: function(sel, total) {
if (sel === 0 || sel === total) {
return 'All users';
} else if (sel > 1) {
return sel + ' users';
} else {
return select.val().map(function(id) {
return by_id[id];
}).join(', ');
}
},
style: 'btn-dark',
actionsBox: true,
selectedTextFormat: 'count',
noneSelectedText: 'All users'
});
select.selectpicker('render');
select.selectpicker('selectAll');
}
});
let history_user_last_id = undefined;
function loadHistoryTable(media_type, transcode_decision, selected_user_id) {
history_table_options.ajax = {
url: 'get_history',
@ -210,16 +187,7 @@
});
$('#history-user').on('change', function () {
let val = $(this).val();
if (val.length === 0 || val.length === $(this).children().length) {
selected_user_id = null; // if all users are selected, just send an empty list
} else {
selected_user_id = val.join(",");
}
if (selected_user_id === history_user_last_id) {
return;
}
history_user_last_id = selected_user_id;
selected_user_id = $(this).val() || null;
history_table.draw();
});
}

View file

@ -32,7 +32,7 @@
<th align="left" id="started">Started</th>
<th align="left" id="paused_counter">Paused</th>
<th align="left" id="stopped">Stopped</th>
<th align="left" id="play_duration">Duration</th>
<th align="left" id="duration">Duration</th>
<th align="left" id="percent_complete"></th>
</tr>
</thead>

View file

@ -77,8 +77,7 @@ DOCUMENTATION :: END
<% fallback = 'art-live' if row0['live'] else 'art' %>
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(${page('pms_image_proxy', row0['art'], row0['rating_key'], 500, 280, 40, '282828', 3, fallback=fallback)});">
% elif stat_id == 'top_libraries':
<% fallback = 'art-live' if row0['live'] else row0['library_art'] %>
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(${page('pms_image_proxy', row0['art'] or row0['library_art'], None, 500, 280, 40, '282828', 3, fallback=fallback)});" data-library_art="${row0['library_art']}">
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(${page('pms_image_proxy', row0['art'] or row0['library_art'], None, 500, 280, 40, '282828', 3, fallback=row0['library_art'])});" data-library_art="${row0['library_art']}">
% elif stat_id == 'top_users':
<div id="stats-background-${stat_id}" class="dashboard-stats-background" data-blurhash="${page('pms_image_proxy', row0['user_thumb'] or 'interfaces/default/images/gravatar-default.png', None, 100, 100, 40, '282828', 0, fallback='user')}">
% elif stat_id == 'top_platforms':
@ -110,8 +109,8 @@ DOCUMENTATION :: END
</a>
</div>
% elif stat_id == 'top_libraries':
% if row0['library_thumb'].startswith('http'):
<div id="stats-thumb-${stat_id}" class="dashboard-stats-flat hidden-xs" style="background-image: url(${page('pms_image_proxy', row0['library_thumb'], None, 100, 100, fallback='cover')});"></div>
% if row0['thumb'].startswith('http'):
<div id="stats-thumb-${stat_id}" class="dashboard-stats-flat hidden-xs" style="background-image: url(${page('pms_image_proxy', row0['thumb'], None, 80, 80)});"></div>
% else:
<div id="stats-thumb-${stat_id}" class="dashboard-stats-flat svg-icon library-${row0['section_type']} hidden-xs"></div>
% endif
@ -148,8 +147,7 @@ DOCUMENTATION :: END
data-rating_key="${row.get('rating_key')}" data-grandparent_rating_key="${row.get('grandparent_rating_key')}" data-guid="${row.get('guid')}" data-title="${row.get('title')}"
data-art="${row.get('art')}" data-thumb="${row.get('thumb')}" data-platform="${row.get('platform_name')}" data-library-type="${row.get('section_type')}"
data-user_id="${row.get('user_id')}" data-user="${row.get('user')}" data-friendly_name="${row.get('friendly_name')}" data-user_thumb="${row.get('user_thumb')}"
data-last_watch="${row.get('last_watch')}" data-started="${row.get('started')}" data-live="${row.get('live')}"
data-library_art="${row.get('library_art', '')}" data-library_thumb="${row.get('library_thumb', '')}">
data-last_watch="${row.get('last_watch')}" data-started="${row.get('started')}" data-live="${row.get('live')}" data-library_art="${row.get('library_art', '')}">
<div class="sub-list">${loop.index + 1}</div>
<div class="sub-value">
% if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'):
@ -177,9 +175,7 @@ DOCUMENTATION :: END
% elif stat_id == 'top_platforms':
${row['platform']}
% elif stat_id == 'most_concurrent':
<a href="graphs#concurrent-graph" title="${row['title']}">
${row['title']}
</a>
${row['title']}
% endif
</div>
<div class="sub-count">

View file

@ -92,10 +92,10 @@
<h3 class="pull-left"><span id="recently-added-xml">Recently Added</span></h3>
<ul class="nav nav-header nav-dashboard pull-right" style="margin-top: -3px;">
<li>
<a href="#" id="recently-added-page-left" class="paginate-added btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
<a href="#" id="recently-added-page-left" class="paginate btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
</li>
<li>
<a href="#" id="recently-added-page-right" class="paginate-added btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
<a href="#" id="recently-added-page-right" class="paginate btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
</li>
</ul>
<div class="button-bar">
@ -212,6 +212,28 @@
</div>
</div>
</div>
<% from plexpy.helpers import anon_url %>
<div id="python2-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="python2-modal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
<h4 class="modal-title">Unable to Update</h4>
</div>
<div class="modal-body" style="text-align: center;">
<p>Tautulli is still running using Python 2 and cannot be updated past v2.6.3.</p>
<p>Python 3 is required to continue receiving updates.</p>
<p>
<strong>Please see the <a href="${anon_url('https://github.com/Tautulli/Tautulli/wiki/Upgrading-to-Python-3-%28Tautulli-v2.5%29')}" target="_blank" rel="noreferrer">wiki</a>
for instructions on how to upgrade to Python 3.</strong>
</p>
</div>
<div class="modal-footer">
<input type="button" class="btn btn-bright" data-dismiss="modal" value="Close">
</div>
</div>
</div>
</div>
% endif
<div class="modal fade" id="ip-info-modal" tabindex="-1" role="dialog" aria-labelledby="ip-info-modal">
@ -298,8 +320,6 @@
$('#currentActivityHeader-bandwidth-tooltip').tooltip({ container: 'body', placement: 'right', delay: 50 });
var title = document.title;
function getCurrentActivity() {
activity_ready = false;
@ -370,8 +390,6 @@
$('#currentActivityHeader').show();
document.title = stream_count + ' stream' + (stream_count > 1 ? 's' : '') + ' | ' + title;
sessions.forEach(function (session) {
var s = (typeof Proxy === "function") ? new Proxy(session, defaultHandler) : session;
var key = s.session_key;
@ -505,15 +523,14 @@
var audio_decision = '';
if (['movie', 'episode', 'clip', 'track'].indexOf(s.media_type) > -1 && s.stream_audio_decision) {
var audio_language = (s.media_type !== 'track') ? (s.audio_language || 'Unknown') + ' - ' : '';
var a_codec = (s.audio_codec === 'truehd') ? 'TrueHD' : s.audio_codec.toUpperCase();
var sa_codec = (s.stream_audio_codec === 'truehd') ? 'TrueHD' : s.stream_audio_codec.toUpperCase();
if (s.stream_audio_decision === 'transcode') {
audio_decision = 'Transcode (' + audio_language + a_codec + ' ' + capitalizeFirstLetter(s.audio_channel_layout.split('(')[0]) + ' <i class="fa fa-long-arrow-right"></i> ' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
audio_decision = 'Transcode ('+ (s.audio_language || 'Unknown')+ ' - ' + a_codec + ' ' + capitalizeFirstLetter(s.audio_channel_layout.split('(')[0]) + ' <i class="fa fa-long-arrow-right"></i> ' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
} else if (s.stream_audio_decision === 'copy') {
audio_decision = 'Direct Stream (' + audio_language + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
audio_decision = 'Direct Stream ('+ (s.audio_language || 'Unknown')+ ' - ' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
} else {
audio_decision = 'Direct Play (' + audio_language + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
audio_decision = 'Direct Play ('+ (s.audio_language || 'Unknown')+ ' - ' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
}
}
$('#audio_decision-' + key).html(audio_decision);
@ -566,7 +583,6 @@
// Update the stream progress times
$('#stream-eta-' + key).html(moment().add(parseInt(s.duration) - parseInt(s.view_offset), 'milliseconds').format(time_format));
$('#stream-duration-' + key).html(millisecondsToMinutes(parseInt(s.stream_duration), false));
var stream_view_offset = $('#stream-view-offset-' + key);
stream_view_offset.data('state', s.state);
if (stream_view_offset.data('last_view_offset') !== s.view_offset) {
@ -604,8 +620,6 @@
} else {
$('#currentActivityHeader').hide();
$('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">Nothing is currently being played.</div>');
document.title = title;
}
activity_ready = true;
@ -783,7 +797,6 @@
var guid = $(elem).data('guid');
var live = $(elem).data('live');
var library_art = $(elem).data('library_art');
var library_thumb = $(elem).data('library_thumb');
var [height, fallback_poster, fallback_art] = [450, 'poster', 'art'];
if ($.inArray(stat_id, ['top_music', 'popular_music']) > -1) {
[height, fallback_poster, fallback_art] = [300, 'cover', 'art'];
@ -795,11 +808,11 @@
if (stat_id === 'most_concurrent') {
return
} else if (stat_id === 'top_libraries') {
$('#stats-background-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', art || library_art, null, 500, 280, 40, '282828', 3, fallback_art) + ')');
$('#stats-background-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', art || library_art, null, 500, 280, 40, '282828', 3, library_art || fallback_art) + ')');
$('#stats-thumb-' + stat_id).removeClass(function (index, className) {
return (className.match (/(^|\s)svg-icon library-\S+/g) || []).join(' ')});
if (library_thumb.startsWith('http')) {
$('#stats-thumb-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', library_thumb, null, 100, 100, null, null, null, 'cover') + ')');
if (thumb.startsWith('http')) {
$('#stats-thumb-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', thumb, null, 300, 300, null, null, null, 'cover') + ')');
} else {
$('#stats-thumb-' + stat_id).css('background-image', '')
.addClass('svg-icon library-' + library_type);
@ -942,14 +955,10 @@
count: recently_added_count,
media_type: recently_added_type
},
beforeSend: function () {
$(".dashboard-recent-media-row").animate({ scrollLeft: 0 }, 1000);
},
complete: function (xhr, status) {
$("#recentlyAdded").html(xhr.responseText);
$('#ajaxMsg').fadeOut();
highlightScrollerButton("#recently-added");
paginateScroller("#recently-added", ".paginate-added");
highlightAddedScrollerButton();
}
});
}
@ -965,11 +974,57 @@
recentlyAdded(recently_added_count, recently_added_type);
}
function highlightAddedScrollerButton() {
var scroller = $("#recently-added-row-scroller");
var numElems = scroller.find("li:visible").length;
scroller.width(numElems * 175);
if (scroller.width() > $("body").find(".container-fluid").width()) {
$("#recently-added-page-right").removeClass("disabled");
} else {
$("#recently-added-page-right").addClass("disabled");
}
}
$(window).resize(function () {
highlightAddedScrollerButton();
});
function resetScroller() {
leftTotal = 0;
$("#recently-added-row-scroller").animate({ left: leftTotal }, 1000);
$("#recently-added-page-left").addClass("disabled").blur();
}
var leftTotal = 0;
$(".paginate").click(function (e) {
e.preventDefault();
var scroller = $("#recently-added-row-scroller");
var containerWidth = $("body").find(".container-fluid").width();
var scrollAmount = $(this).data("id") * parseInt((containerWidth - 15) / 175) * 175;
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
leftTotal = Math.max(Math.min(leftTotal + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotal }, 250);
if (leftTotal === 0) {
$("#recently-added-page-left").addClass("disabled").blur();
} else {
$("#recently-added-page-left").removeClass("disabled");
}
if (leftTotal === leftMax) {
$("#recently-added-page-right").addClass("disabled").blur();
} else {
$("#recently-added-page-right").removeClass("disabled");
}
});
$('#recently-added-toggles').on('change', function () {
$('#recently-added-toggles > label').removeClass('active');
selected_filter = $('input[name=recently-added-toggle]:checked', '#recently-added-toggles');
$(selected_filter).closest('label').addClass('active');
recently_added_type = $(selected_filter).val();
resetScroller();
setLocalStorage('home_stats_recently_added_type', recently_added_type);
recentlyAdded(recently_added_count, recently_added_type);
});
@ -977,6 +1032,7 @@
$('#recently-added-count').change(function () {
forceMinMax($(this));
recently_added_count = $(this).val();
resetScroller();
setLocalStorage('home_stats_recently_added_count', recently_added_count);
recentlyAdded(recently_added_count, recently_added_type);
});
@ -1008,4 +1064,16 @@
});
</script>
% endif
% if _session['user_group'] == 'admin':
<script>
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
if (urlParams.get('update') === 'python2') {
$("#python2-modal").modal({
backdrop: 'static',
keyboard: false
});
}
</script>
% endif
</%def>

View file

@ -408,8 +408,8 @@ DOCUMENTATION :: END
% endif
</div>
<div class="summary-content-details-tag" id="channel-icon">
% 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_title'] or (media_info['channel_vcn'] + ' ' + media_info['channel_call_sign'])}</span> </strong>
% if media_info['channel_identifier']:
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>
% endif
</div>
</div>
@ -692,7 +692,7 @@ DOCUMENTATION :: END
<th align="left" id="started">Started</th>
<th align="left" id="paused_counter">Paused</th>
<th align="left" id="stopped">Stopped</th>
<th align="left" id="play_duration">Duration</th>
<th align="left" id="duration">Duration</th>
<th align="left" id="percent_complete"></th>
</tr>
</thead>
@ -878,7 +878,7 @@ DOCUMENTATION :: END
transcode_decision: transcode_decision,
user_id: "${history_user_id}",
% if data['live']:
guid: "${data['guid']}"
guid: "${data['guid']}
% elif data['media_type'] in ('show', 'artist'):
grandparent_rating_key: "${data['rating_key']}"
% elif data['media_type'] in ('season', 'album'):
@ -947,12 +947,8 @@ DOCUMENTATION :: END
url: 'item_watch_time_stats',
async: true,
data: {
% if data['live']:
guid: "${data['guid']}"
% else:
rating_key: "${data['rating_key']}",
media_type: "${data['media_type']}"
% endif
media_type: "${data['media_type']}"
},
complete: function(xhr, status) {
$("#watch-time-stats").html(xhr.responseText);
@ -963,12 +959,8 @@ DOCUMENTATION :: END
url: 'item_user_stats',
async: true,
data: {
% if data['live']:
guid: "${data['guid']}"
% else:
rating_key: "${data['rating_key']}",
media_type: "${data['media_type']}"
% endif
media_type: "${data['media_type']}"
},
complete: function(xhr, status) {
$("#user-stats").html(xhr.responseText);

File diff suppressed because one or more lines are too long

View file

@ -1,76 +0,0 @@
var formatter_function = function() {
if (moment(this.x, 'X').isValid() && (this.x > 946684800)) {
var s = '<b>'+ moment(this.x).format('ddd MMM D') +'</b>';
} else {
var s = '<b>'+ this.x +'</b>';
}
$.each(this.points, function(i, point) {
s += '<br/>'+point.series.name+': '+point.y;
});
return s;
};
var hc_concurrent_streams_by_stream_type_options = {
chart: {
type: 'line',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'graph_concurrent_streams_by_stream_type'
},
title: {
text: ''
},
legend: {
enabled: true,
itemStyle: {
font: '9pt "Open Sans", sans-serif',
color: '#A0A0A0'
},
itemHoverStyle: {
color: '#FFF'
},
itemHiddenStyle: {
color: '#444'
}
},
credits: {
enabled: false
},
plotOptions: {
series: {
events: {
legendItemClick: function() {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},
xAxis: {
type: 'datetime',
labels: {
formatter: function() {
return moment(this.value).format("MMM D");
},
style: {
color: '#aaa'
}
},
categories: [{}],
plotBands: []
},
yAxis: {
title: {
text: null
},
labels: {
style: {
color: '#aaa'
}
}
},
tooltip: {
shared: true,
crosshairs: true,
formatter: formatter_function
},
series: [{}]
};

File diff suppressed because one or more lines are too long

View file

@ -288,10 +288,23 @@ function isPrivateIP(ip_address) {
}
function humanTime(seconds) {
if (seconds > 0) {
return humanDuration(seconds * 1000).replaceAll(/(\d+) (\w+)/g, '<h3>$1</h3><p>$2</p>')
var d = Math.floor(moment.duration(seconds, 'seconds').asDays());
var h = Math.floor(moment.duration((seconds % 86400), 'seconds').asHours());
var m = Math.round(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes());
var text = '';
if (d > 0) {
text = '<h3>' + d + '</h3><p> day' + ((d > 1) ? 's' : '') + '</p>'
+ '<h3>' + h + '</h3><p> hr' + ((h > 1) ? 's' : '') + '</p>'
+ '<h3>' + m + '</h3><p> min' + ((m > 1) ? 's' : '') + '</p>';
} else if (h > 0) {
text = '<h3>' + h + '</h3><p> hr' + ((h > 1) ? 's' : '') + '</p>'
+ '<h3>' + m + '</h3><p> min' + ((m > 1) ? 's' : '') + '</p>';
} else {
text = '<h3>' + m + '</h3><p> min' + ((m > 1) ? 's' : '') + '</p>';
}
return "<h3>0</h3><p>mins</p>";
return text
}
String.prototype.toProperCase = function () {
@ -347,8 +360,7 @@ function humanDuration(ms, sig='dhm', units='ms', return_seconds=300000) {
sig = 'dhms'
}
r = factors[sig.slice(-1)];
ms = Math.round(ms * factors[units] / r) * r;
ms = ms * factors[units];
h = ms % factors['d'];
d = Math.trunc(ms / factors['d']);
@ -917,50 +929,3 @@ $('.modal').on('hide.bs.modal', function (e) {
$.fn.hasScrollBar = function() {
return this.get(0).scrollHeight > this.get(0).clientHeight;
}
function paginateScroller(scrollerId, buttonClass) {
$(buttonClass).click(function (e) {
e.preventDefault();
var scroller = $(scrollerId + "-row-scroller");
var scrollerParent = scroller.parent();
var containerWidth = scrollerParent.width();
var scrollCurrent = scrollerParent.scrollLeft();
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
var scrollMax = scroller.width() - Math.abs(scrollAmount);
var scrollTotal = Math.min(parseInt(scrollCurrent / 175) * 175 + scrollAmount, scrollMax);
scrollerParent.animate({ scrollLeft: scrollTotal }, 250);
});
}
function highlightScrollerButton(scrollerId) {
var scroller = $(scrollerId + "-row-scroller");
var scrollerParent = scroller.parent();
var buttonLeft = $(scrollerId + "-page-left");
var buttonRight = $(scrollerId + "-page-right");
var numElems = scroller.find("li").length;
scroller.width(numElems * 175);
$(buttonLeft).addClass("disabled").blur();
if (scroller.width() > scrollerParent.width()) {
$(buttonRight).removeClass("disabled");
} else {
$(buttonRight).addClass("disabled");
}
scrollerParent.scroll(function () {
var scrollCurrent = $(this).scrollLeft();
var scrollMax = scroller.width() - $(this).width();
if (scrollCurrent == 0) {
$(buttonLeft).addClass("disabled").blur();
} else {
$(buttonLeft).removeClass("disabled");
}
if (scrollCurrent >= scrollMax) {
$(buttonRight).addClass("disabled").blur();
} else {
$(buttonRight).removeClass("disabled");
}
});
}

View file

@ -100,7 +100,7 @@ export_table_options = {
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var images = '';
if (rowData['thumb_level'] || rowData['art_level'] || rowData['logo_level']) {
if (rowData['thumb_level'] || rowData['art_level']) {
images = ' + images';
}
$(td).html(cellData + images);
@ -161,14 +161,14 @@ export_table_options = {
if (cellData === 1 && rowData['exists']) {
var tooltip_title = '';
var icon = '';
if (rowData['thumb_level'] || rowData['art_level'] || rowData['logo_level'] || rowData['individual_files']) {
tooltip_title = 'ZIP Archive';
if (rowData['thumb_level'] || rowData['art_level'] || rowData['individual_files']) {
tooltip_title = 'Zip Archive';
icon = 'fa-file-archive';
} else {
tooltip_title = rowData['file_format'].toUpperCase() + ' File';
icon = 'fa-file-download';
}
var icon = (rowData['thumb_level'] || rowData['art_level'] || rowData['logo_level'] || rowData['individual_files']) ? 'fa-file-archive' : 'fa-file-download';
var icon = (rowData['thumb_level'] || rowData['art_level'] || rowData['individual_files']) ? 'fa-file-archive' : 'fa-file-download';
$(td).html('<button class="btn btn-xs btn-success pull-left" data-id="' + rowData['export_id'] + '"><span data-toggle="tooltip" data-placement="left" title="' + tooltip_title + '"><i class="fa ' + icon + ' fa-fw"></i> Download</span></button>');
} else if (cellData === 0) {
var percent = Math.min(getPercent(rowData['exported_items'], rowData['total_items']), 99)

View file

@ -247,7 +247,7 @@ history_table_options = {
},
{
"targets": [11],
"data": "play_duration",
"data": "duration",
"render": function (data, type, full) {
if (data !== null) {
return Math.round(moment.duration(data, 'seconds').as('minutes')) + ' mins';
@ -263,17 +263,13 @@ history_table_options = {
"targets": [12],
"data": "watched_status",
"createdCell": function (td, cellData, rowData, row, col) {
var circleValue = "";
if (cellData == 1) {
circleValue = " circle-full";
} else if (cellData == 0.75) {
circleValue = " circle-three-quarter";
$(td).html('<span class="watched-tooltip" data-toggle="tooltip" title="' + rowData['percent_complete'] + '%"><i class="fa fa-lg fa-circle"></i></span>');
} else if (cellData == 0.5) {
circleValue = " circle-half";
} else if (cellData == 0.25) {
circleValue = " circle-quarter";
$(td).html('<span class="watched-tooltip" data-toggle="tooltip" title="' + rowData['percent_complete'] + '%"><i class="fa fa-lg fa-adjust fa-rotate-180"></i></span>');
} else {
$(td).html('<span class="watched-tooltip" data-toggle="tooltip" title="' + rowData['percent_complete'] + '%"><i class="fa fa-lg fa-circle-o"></i></span>');
}
$(td).html('<span class="watched-tooltip" data-toggle="tooltip" title="' + rowData['percent_complete'] + '%"><div class="circle' + circleValue + '" /></span>');
},
"searchable": false,
"orderable": false,
@ -533,7 +529,7 @@ function childTableFormat(rowData) {
'<th align="left" id="started">Started</th>' +
'<th align="left" id="paused_counter">Paused</th>' +
'<th align="left" id="stopped">Stopped</th>' +
'<th align="left" id="play_duration">Duration</th>' +
'<th align="left" id="duration">Duration</th>' +
'<th align="left" id="percent_complete"></th>' +
'</tr>' +
'</thead>' +

View file

@ -149,10 +149,10 @@ DOCUMENTATION :: END
<div class="table-card-header">
<ul class="nav nav-header nav-dashboard pull-right">
<li>
<a href="#" id="recently-watched-page-left" class="paginate-watched btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
<a href="#" id="recently-watched-page-left" class="paginate-watched btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
</li>
<li>
<a href="#" id="recently-watched-page-right" class="paginate-watched btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
<a href="#" id="recently-watched-page-right" class="paginate-watched btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
</li>
</ul>
<div class="header-bar">
@ -175,10 +175,10 @@ DOCUMENTATION :: END
<div class="table-card-header">
<ul class="nav nav-header nav-dashboard pull-right">
<li>
<a href="#" id="recently-added-page-left" class="paginate-added btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
<a href="#" id="recently-added-page-left" class="paginate-added btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
</li>
<li>
<a href="#" id="recently-added-page-right" class="paginate-added btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
<a href="#" id="recently-added-page-right" class="paginate-added btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
</li>
</ul>
<div class="header-bar">
@ -248,7 +248,7 @@ DOCUMENTATION :: END
<th align="left" id="started">Started</th>
<th align="left" id="paused_counter">Paused</th>
<th align="left" id="stopped">Stopped</th>
<th align="left" id="play_duration">Duration</th>
<th align="left" id="duration">Duration</th>
<th align="left" id="percent_complete"></th>
</tr>
</thead>
@ -690,8 +690,7 @@ DOCUMENTATION :: END
},
complete: function(xhr, status) {
$("#library-recently-watched").html(xhr.responseText);
highlightScrollerButton("#recently-watched");
paginateScroller("#recently-watched", ".paginate-watched");
highlightWatchedScrollerButton();
}
});
}
@ -707,8 +706,7 @@ DOCUMENTATION :: END
},
complete: function(xhr, status) {
$("#library-recently-added").html(xhr.responseText);
highlightScrollerButton("#recently-added");
paginateScroller("#recently-added", ".paginate-added");
highlightAddedScrollerButton();
}
});
}
@ -718,8 +716,83 @@ DOCUMENTATION :: END
recentlyAdded();
% endif
function highlightWatchedScrollerButton() {
var scroller = $("#recently-watched-row-scroller");
var numElems = scroller.find("li").length;
scroller.width(numElems * 175);
if (scroller.width() > $("#library-recently-watched").width()) {
$("#recently-watched-page-right").removeClass("disabled");
} else {
$("#recently-watched-page-right").addClass("disabled");
}
}
function highlightAddedScrollerButton() {
var scroller = $("#recently-added-row-scroller");
var numElems = scroller.find("li").length;
scroller.width(numElems * 175);
if (scroller.width() > $("#library-recently-added").width()) {
$("#recently-added-page-right").removeClass("disabled");
} else {
$("#recently-added-page-right").addClass("disabled");
}
}
$(window).resize(function() {
highlightWatchedScrollerButton();
highlightAddedScrollerButton();
});
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });
var leftTotalWatched = 0;
$(".paginate-watched").click(function (e) {
e.preventDefault();
var scroller = $("#recently-watched-row-scroller");
var containerWidth = $("#library-recently-watched").width();
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
leftTotalWatched = Math.max(Math.min(leftTotalWatched + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotalWatched }, 250);
if (leftTotalWatched == 0) {
$("#recently-watched-page-left").addClass("disabled").blur();
} else {
$("#recently-watched-page-left").removeClass("disabled");
}
if (leftTotalWatched == leftMax) {
$("#recently-watched-page-right").addClass("disabled").blur();
} else {
$("#recently-watched-page-right").removeClass("disabled");
}
});
var leftTotalAdded = 0;
$(".paginate-added").click(function (e) {
e.preventDefault();
var scroller = $("#recently-added-row-scroller");
var containerWidth = $("#library-recently-added").width();
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
leftTotalAdded = Math.max(Math.min(leftTotalAdded + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotalAdded }, 250);
if (leftTotalAdded == 0) {
$("#recently-added-page-left").addClass("disabled").blur();
} else {
$("#recently-added-page-left").removeClass("disabled");
}
if (leftTotalAdded == leftMax) {
$("#recently-added-page-right").addClass("disabled").blur();
} else {
$("#recently-added-page-right").removeClass("disabled");
}
});
$(document).ready(function () {
// Javascript to enable link to tab

View file

@ -36,7 +36,7 @@ DOCUMENTATION :: END
%>
<div class="dashboard-recent-media-row">
<div id="recently-added-row-scroller">
<div id="recently-added-row-scroller" style="left: 0;">
<ul class="dashboard-recent-media list-unstyled">
% for item in data:
<li>

View file

@ -11,7 +11,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
<meta name="referrer" content="no-referrer">
<link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet">
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/opensans.min.css" rel="stylesheet">

View file

@ -453,12 +453,12 @@
$("#download-tautullilog").click(function () {
var logfile = $(".tab-pane.active").data('logfile');
window.location.href = "download_log?logfile=" + window.encodeURIComponent(logfile);
window.location.href = "download_log?logfile=" + logfile;
});
$("#download-plexserverlog").click(function () {
var logfile = $("option:selected", "#plex-log-files").val();
window.location.href = "download_plex_log?logfile=" + window.encodeURIComponent(logfile);
window.location.href = "download_plex_log?logfile=" + logfile;
});
$("#clear-notify-logs").click(function () {

View file

@ -3,7 +3,7 @@
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
<h4 class="modal-title" id="mobile-device-config-modal-header">${device['device_name']} Settings &nbsp;<small><span class="device_id">(Device ID: ${device['id']}${' - ' + device['friendly_name'] if device['friendly_name'] else ''})</span></small></h4>
<h4 class="modal-title" id="mobile-device-config-modal-header">${device['device_name']} Settings &nbsp;<small><span class="device_id">(Device ID: ${device['id']})</span></small></h4>
</div>
<div class="modal-body">
<div class="container-fluid">

View file

@ -28,17 +28,15 @@ DOCUMENTATION :: END
<span class="toggle-left official-tooltip" data-toggle="tooltip" data-placement="top" title="Unofficial or Unknown App"><i class="fa fa-lg fa-fw fa-exclamation-triangle"></i></span>
% endif
${device['friendly_name'] or device['device_name']} &nbsp;<span class="friendly_name">(${device['id']})</span>
<span class="toggle-right friendly_name">
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
<span class="toggle-right friendly_name" id="device-last_seen-${device['id']}">
% if device['last_seen']:
<span id="device-last_seen-${device['id']}">
<script>
$("#device-last_seen-${device['id']}").text(moment("${device['last_seen']}", "X").fromNow())
</script>
</span>
<script>
$("#device-last_seen-${device['id']}").text(moment("${device['last_seen']}", "X").fromNow())
</script>
% else:
never
% endif
<i class="fa fa-lg fa-fw fa-cog"></i></span>
</span>
</span>
</li>
@ -72,7 +70,7 @@ DOCUMENTATION :: END
function checkQRAddress(url) {
var parser = document.createElement('a');
parser.setAttribute('href', url);
parser.href = url;
var hostname = parser.hostname;
var protocol = parser.protocol;

View file

@ -13,7 +13,7 @@
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
<h4 class="modal-title" id="newsletter-config-modal-header">${newsletter['agent_label']} Newsletter Settings &nbsp;<small><span class="newsletter_id">(Newsletter ID: ${newsletter['id']}${' - ' + newsletter['friendly_name'] if newsletter['friendly_name'] else ''})</span></small></h4>
<h4 class="modal-title" id="newsletter-config-modal-header">${newsletter['agent_label']} Newsletter Settings &nbsp;<small><span class="newsletter_id">(Newsletter ID: ${newsletter['id']})</span></small></h4>
</div>
<div class="modal-body">
<div class="container-fluid">
@ -32,7 +32,7 @@
<div class="col-md-12">
<div class="checkbox" style="margin-bottom: 20px;">
<label>
<input type="checkbox" data-id="active_value" class="checkboxes" value="1" autocomplete="off" ${checked(newsletter['active'])}> Enable the Newsletter
<input type="checkbox" data-id="active_value" class="checkboxes" value="1" ${checked(newsletter['active'])}> Enable the Newsletter
</label>
<input type="hidden" id="active_value" name="active" value="${newsletter['active']}">
</div>
@ -40,20 +40,17 @@
<label for="custom_cron">Schedule</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="custom_cron" name="newsletter_config_custom_cron" autocomplete="off">
<select class="form-control" id="custom_cron" name="newsletter_config_custom_cron">
<option value="0" ${'selected' if newsletter['config']['custom_cron'] == 0 else ''}>Simple</option>
<option value="1" ${'selected' if newsletter['config']['custom_cron'] == 1 else ''}>Custom</option>
</select>
<input type="text" id="cron_value" name="cron" value="${newsletter['cron']}" autocomplete="off" />
<input type="text" id="cron_value" name="cron" value="${newsletter['cron']}" />
<div id="cron-widget"></div>
</div>
</div>
<p class="help-block">
<span id="simple_cron_message">Set the schedule for the newsletter.</span>
<span id="custom_cron_message">
Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank" rel="noreferrer">custom crontab</a>.
<a href="${anon_url('https://apscheduler.readthedocs.io/en/3.x/modules/triggers/cron.html#expression-types')}" target="_blank" rel="noreferrer">Click here</a> for a list of supported expressions.
</span>
<span id="custom_cron_message">Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank" rel="noreferrer">custom crontab</a>. Only standard cron values are valid.</span>
</p>
</div>
<div class="form-group">
@ -62,8 +59,8 @@
<div class="col-md-5">
<div class="input-group newsletter-time_frame">
<span class="input-group-addon form-control btn-dark inactive">Last</span>
<input type="number" class="form-control" id="newsletter_config_time_frame" name="newsletter_config_time_frame" value="${newsletter['config']['time_frame']}" autocomplete="off">
<select class="form-control" id="newsletter_config_time_frame_units" name="newsletter_config_time_frame_units" autocomplete="off">
<input type="number" class="form-control" id="newsletter_config_time_frame" name="newsletter_config_time_frame" value="${newsletter['config']['time_frame']}">
<select class="form-control" id="newsletter_config_time_frame_units" name="newsletter_config_time_frame_units">
<option value="months" ${'selected' if newsletter['config']['time_frame_units'] == 'months' else ''}>months</option>
<option value="days" ${'selected' if newsletter['config']['time_frame_units'] == 'days' else ''}>days</option>
<option value="hours" ${'selected' if newsletter['config']['time_frame_units'] == 'hours' else ''}>hours</option>
@ -88,7 +85,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off" ${'readonly' if item.get('readonly') else ''}>
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
@ -98,7 +95,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-3">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30">
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
@ -116,7 +113,7 @@
% elif item['input_type'] == 'checkbox':
<div class="checkbox">
<label>
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" autocomplete="off" ${checked(item['value'])}> ${item['label']}
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
</label>
<p class="help-block">${item['description'] | n}</p>
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
@ -126,7 +123,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
<select class="form-control" id="${item['name']}" name="${item['name']}">
% for key, value in sorted(item['select_options'].items()):
% if key == item['value']:
<option value="${key}" selected>${value}</option>
@ -144,7 +141,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
<select class="form-control" id="${item['name']}" name="${item['name']}">
% if item['select_all']:
<option value="select-all">Select All</option>
<option value="remove-all">Remove All</option>
@ -178,7 +175,7 @@
<label for="id_name">Unique ID Name</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="id_name" name="id_name" value="${newsletter['id_name']}" size="30" autocomplete="off">
<input type="text" class="form-control" id="id_name" name="id_name" value="${newsletter['id_name']}" size="30">
</div>
</div>
<p class="help-block">
@ -191,7 +188,7 @@
<label for="friendly_name">Description</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${newsletter['friendly_name']}" size="30" autocomplete="off">
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${newsletter['friendly_name']}" size="30">
</div>
</div>
<p class="help-block">Optional: Enter a description to help identify this newsletter in the newsletters list.</p>
@ -205,7 +202,7 @@
<label>Saving</label>
<div class="checkbox">
<label>
<input type="checkbox" id="newsletter_config_save_only_checkbox" data-id="newsletter_config_save_only" class="checkboxes" value="1" autocomplete="off" ${checked(newsletter['config']['save_only'])}> Save HTML File Only
<input type="checkbox" id="newsletter_config_save_only_checkbox" data-id="newsletter_config_save_only" class="checkboxes" value="1" ${checked(newsletter['config']['save_only'])}> Save HTML File Only
</label>
<p class="help-block">Enable to save the newsletter HTML file without sending it to any notification agent.</p>
<input type="hidden" id="newsletter_config_save_only" name="newsletter_config_save_only" value="${newsletter['config']['save_only']}">
@ -214,7 +211,7 @@
<label for="newsletter_config_filename">HTML File Name</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="newsletter_config_filename" name="newsletter_config_filename" value="${newsletter['config']['filename']}" size="30" autocomplete="off">
<input type="text" class="form-control" id="newsletter_config_filename" name="newsletter_config_filename" value="${newsletter['config']['filename']}" size="30">
</div>
</div>
<p class="help-block">Optional: Enter the file name to use when saving the newsletter (ending with <span class="inline-pre">.html</span>). You may use any of the <a href="#newsletter-text-sub-modal" data-toggle="modal">newsletter text parameters</a>. Leave blank for default.</p>
@ -224,7 +221,7 @@
<label>Sending</label>
<div class="checkbox">
<label>
<input type="checkbox" id="newsletter_config_formatted_checkbox" data-id="newsletter_config_formatted" class="checkboxes" value="1" autocomplete="off" ${checked(newsletter['config']['formatted'])}> Send Newsletter as an HTML Formatted Email
<input type="checkbox" id="newsletter_config_formatted_checkbox" data-id="newsletter_config_formatted" class="checkboxes" value="1" ${checked(newsletter['config']['formatted'])}> Send Newsletter as an HTML Formatted Email
</label>
<p class="help-block">Enable to send the newsletter as an HTML formatted Email. Disable to only send a subject and body message to a different notification agent.</p>
<input type="hidden" id="newsletter_config_formatted" name="newsletter_config_formatted" value="${newsletter['config']['formatted']}">
@ -232,7 +229,7 @@
<div class="form-group" id="email_notifier_select">
<div class="checkbox">
<label>
<input type="checkbox" id="newsletter_config_threaded_checkbox" data-id="newsletter_config_threaded" class="checkboxes" value="1" autocomplete="off" ${checked(newsletter['config']['threaded'])}> Enable Grouped Email Thread
<input type="checkbox" id="newsletter_config_threaded_checkbox" data-id="newsletter_config_threaded" class="checkboxes" value="1" ${checked(newsletter['config']['threaded'])}> Enable Grouped Email Thread
</label>
<p class="help-block">Enable to group this newsletter together in a single Email thread. Disable to send a new Email for each newsletter.</p>
<input type="hidden" id="newsletter_config_threaded" name="newsletter_config_threaded" value="${newsletter['config']['threaded']}">
@ -240,7 +237,7 @@
<label for="newsletter_email_notifier_id">Email Notification Agent</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="newsletter_email_notifier_id" name="newsletter_email_notifier_id" autocomplete="off">
<select class="form-control" id="newsletter_email_notifier_id" name="newsletter_email_notifier_id">
% for notifier in email_notifiers:
<% selected = 'selected' if notifier['id'] == newsletter['email_config']['notifier_id'] else '' %>
% if notifier['friendly_name']:
@ -263,7 +260,7 @@
<label for="newsletter_config_notifier_id">Notification Agent</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="newsletter_config_notifier_id" name="newsletter_config_notifier_id" autocomplete="off">
<select class="form-control" id="newsletter_config_notifier_id" name="newsletter_config_notifier_id">
% for notifier in other_notifiers:
<% selected = 'selected' if notifier['id'] == newsletter['config']['notifier_id'] else '' %>
% if notifier['friendly_name']:
@ -294,7 +291,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off" ${'readonly' if item.get('readonly') else ''}>
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
@ -304,7 +301,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-3">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30">
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
@ -322,7 +319,7 @@
% elif item['input_type'] == 'checkbox' and item['name'] != 'newsletter_email_html_support':
<div class="checkbox">
<label>
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" autocomplete="off" ${checked(item['value'])}> ${item['label']}
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
</label>
<p class="help-block">${item['description'] | n}</p>
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
@ -332,7 +329,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
<select class="form-control" id="${item['name']}" name="${item['name']}">
% for key, value in sorted(item['select_options'].items()):
% if isinstance(value, list):
<optgroup label="${key}">
@ -354,7 +351,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
<select class="form-control" id="${item['name']}" name="${item['name']}">
<option value="select-all">Select All</option>
<option value="remove-all">Remove All</option>
% if isinstance(item['select_options'], dict):
@ -399,7 +396,7 @@
<label for="subject">Subject</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="subject" name="subject" value="${newsletter['subject']}" size="30" autocomplete="off">
<input type="text" class="form-control" id="subject" name="subject" value="${newsletter['subject']}" size="30">
</div>
</div>
<p class="help-block">
@ -410,7 +407,7 @@
<label for="body">Body</label>
<div class="row">
<div class="col-md-12">
<textarea class="form-control" id="body" name="body" autocomplete="off" data-autoresize>${newsletter['body']}</textarea>
<textarea class="form-control" id="body" name="body" data-autoresize>${newsletter['body']}</textarea>
</div>
</div>
<p class="help-block">
@ -421,7 +418,7 @@
<label for="message">Message</label>
<div class="row">
<div class="col-md-12">
<textarea class="form-control" id="message" name="message" autocomplete="off" data-autoresize>${newsletter['message']}</textarea>
<textarea class="form-control" id="message" name="message" data-autoresize>${newsletter['message']}</textarea>
</div>
</div>
<p class="help-block">
@ -484,7 +481,7 @@
});
if (${newsletter['config']['custom_cron']}) {
$('#cron_value').val('${newsletter['cron'] | n}');
$('#cron_value').val('${newsletter['cron']}');
} else {
try {
cron_widget.cron('value', '${newsletter['cron']}');

View file

@ -1,5 +1,5 @@
<%
from urllib.parse import urlencode
from six.moves.urllib.parse import urlencode
%>
<!doctype html>

View file

@ -20,28 +20,13 @@ DOCUMENTATION :: END
% else:
${newsletter['agent_label']} &nbsp;<span class="friendly_name">(${newsletter['id']})</span>
% endif
<span class="toggle-right friendly_name">
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
<span class="toggle-right friendly_name" id="newsletter-next_run-${newsletter['id']}">
% if newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])):
<% job = newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])) %>
<span id="newsletter-next_run-${newsletter['id']}">
<script>
$("#newsletter-next_run-${newsletter['id']}").text(
"Next: " + moment("${job.next_run_time}", "YYYY-MM-DD HH:mm:ssZ").fromNow() + " | ")
</script>
</span>
% endif
% if newsletter['last_triggered']:
<% icon, icon_tooltip = ('fa-check', 'Success') if newsletter['last_success'] else ('fa-times', 'Failed') %>
<span id="newsletter-last_triggered-${newsletter['id']}">
<script>
$("#newsletter-last_triggered-${newsletter['id']}").html(
"Last: " + moment("${newsletter['last_triggered']}", "X").fromNow() + ' <i class="fa fa-lg fa-fw ${icon}" data-toggle="tooltip" data-placement="top" title="${icon_tooltip}"></i>'
)
</script>
</span>
% else:
Last: never
<i class="fa fa-lg fa-fw fa-minus"></i>
<script>
$("#newsletter-next_run-${newsletter['id']}").text(moment("${job.next_run_time}", "YYYY-MM-DD HH:mm:ssZ").fromNow())
</script>
% endif
</span>
</span>

View file

@ -12,7 +12,7 @@
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
<h4 class="modal-title" id="notifier-config-modal-header">${notifier['agent_label']} Settings &nbsp;<small><span class="notifier_id">(Notifier ID: ${notifier['id']}${' - ' + notifier['friendly_name'] if notifier['friendly_name'] else ''})</span></small></h4>
<h4 class="modal-title" id="notifier-config-modal-header">${notifier['agent_label']} Settings &nbsp;<small><span class="notifier_id">(Notifier ID: ${notifier['id']})</span></small></h4>
</div>
<div class="modal-body">
<div class="container-fluid">
@ -51,13 +51,13 @@
<div class="col-md-12">
% if notifier['agent_name'] == 'scripts' and item['name'] == 'scripts_script_folder':
<div class="input-group">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off" ${'readonly' if item.get('readonly') else ''}>
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
<span class="input-group-btn">
<button class="btn btn-form" type="button" id="${item['name']}_browse" data-toggle="browse" data-filter=".folderonly" data-target="#${item['name']}">Browse</button>
</span>
</div>
% else:
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off" ${'readonly' if item.get('readonly') else ''}>
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
% endif
</div>
</div>
@ -72,7 +72,7 @@
<span class="input-group-btn">
<button class="btn btn-form reveal-token" type="button"><i class="fa fa-eye-slash"></i></button>
</span>
<input type="password" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off" ${'readonly' if item.get('readonly') else ''}>
<input type="password" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
</div>
</div>
</div>
@ -83,7 +83,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-3">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30">
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
@ -101,7 +101,7 @@
% elif item['input_type'] == 'checkbox':
<div class="checkbox">
<label>
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" autocomplete="off" ${checked(item['value'])}> ${item['label']}
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
</label>
<p class="help-block">${item['description'] | n}</p>
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
@ -111,7 +111,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
<select class="form-control" id="${item['name']}" name="${item['name']}">
% for key, value in sorted(item['select_options'].items()):
% if isinstance(value, list):
<optgroup label="${key}">
@ -133,7 +133,7 @@
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
<select class="form-control" id="${item['name']}" name="${item['name']}">
% if item['select_all']:
<option value="select-all">Select All</option>
<option value="remove-all">Remove All</option>
@ -167,7 +167,7 @@
<label for="friendly_name">Description</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${notifier['friendly_name']}" size="30" autocomplete="off">
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${notifier['friendly_name']}" size="30">
</div>
</div>
<p class="help-block">Optional: Enter a description to help identify this agent in the notification agents list.</p>
@ -185,7 +185,7 @@
% for action in available_notification_actions:
<div class="checkbox">
<label>
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" autocomplete="off" ${checked(notifier['actions'][action['name']])}> ${action['label']}
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${checked(notifier['actions'][action['name']])}> ${action['label']}
</label>
<p class="help-block">${action['description'] | n}</p>
<input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}">
@ -208,7 +208,7 @@
<div class="form-group">
<label for="custom_conditions_logic">Condition Logic</label>
<input type="text" class="form-control" name="custom_conditions_logic" id="custom_conditions_logic" value="${notifier['custom_conditions_logic']}" autocomplete="off" />
<input type="text" class="form-control" name="custom_conditions_logic" id="custom_conditions_logic" value="${notifier['custom_conditions_logic']}" />
<div id="custom_conditions_logic_error" class="alert alert-danger" role="alert" style="padding-top: 5px; padding-bottom: 5px; margin: 0; display: none;"><i class="fa fa-exclamation-triangle" style="color: #a94442;"></i> <span></span></div>
<p class="help-block">
Optional: Enter custom logic to use when evaluating the conditions (e.g. <span class="inline-pre">{1} and ({2} or {3})</span>).
@ -254,7 +254,7 @@
<li>
<div class="form-group">
<label for="${action['name']}_subject">Script Arguments</label>
<input class="form-control" type="text" id="${action['name']}_subject" name="${action['name']}_subject" value="${notifier['notify_text'][action['name']]['subject']}" autocomplete="off" data-parsley-trigger="change" required>
<input class="form-control" type="text" id="${action['name']}_subject" name="${action['name']}_subject" value="${notifier['notify_text'][action['name']]['subject']}" data-parsley-trigger="change" required>
<p class="help-block">Set custom arguments passed to the script.</p>
</div>
<div class="form-group">
@ -280,12 +280,12 @@
<li>
<div class="form-group">
<label for="${action['name']}_subject">JSON Headers</label>
<textarea class="form-control" id="${action['name']}_subject" name="${action['name']}_subject" data-parsley-trigger="change" autocomplete="off" data-autoresize required>${notifier['notify_text'][action['name']]['subject']}</textarea>
<textarea class="form-control" id="${action['name']}_subject" name="${action['name']}_subject" data-parsley-trigger="change" data-autoresize required>${notifier['notify_text'][action['name']]['subject']}</textarea>
<p class="help-block">Set custom JSON headers.</p>
</div>
<div class="form-group">
<label for="${action['name']}_body">JSON Data</label>
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" autocomplete="off" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
<p class="help-block">Set custom JSON data.</p>
</div>
<div class="form-group">
@ -311,12 +311,12 @@
<li>
<div class="form-group">
<label for="${action['name']}_subject">Subject Line</label>
<input class="form-control" type="text" id="${action['name']}_subject" name="${action['name']}_subject" value="${notifier['notify_text'][action['name']]['subject']}" autocomplete="off" data-parsley-trigger="change" required>
<input class="form-control" type="text" id="${action['name']}_subject" name="${action['name']}_subject" value="${notifier['notify_text'][action['name']]['subject']}" data-parsley-trigger="change" required>
<p class="help-block">Set a custom subject line.</p>
</div>
<div class="form-group">
<label for="${action['name']}_body">Message Body</label>
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" autocomplete="off" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
<p class="help-block">Set a custom body.</p>
</div>
<div class="form-group">
@ -347,7 +347,7 @@
<label for="test_script">Script</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="test_script" name="test_script" autocomplete="off">
<select class="form-control" id="test_script" name="test_script">
% for key, value in sorted(notifier['config_options'][2]['select_options'].items()):
<option value="${key}">${value}</option>
% endfor
@ -360,7 +360,7 @@
<label for="test_script_args">Script Arguments</label>
<div class="row">
<div class="col-md-12">
<input class="form-control" type="text" id="test_script_args" name="test_script_args" value="" autocomplete="off">
<input class="form-control" type="text" id="test_script_args" name="test_script_args" value="">
</div>
</div>
<p class="help-block">Set custom arguments passed to the script.</p>
@ -370,7 +370,7 @@
<label for="test_subject">JSON Headers</label>
<div class="row">
<div class="col-md-12">
<textarea class="form-control" id="test_subject" name="test_subject" autocomplete="off" data-autoresize></textarea>
<textarea class="form-control" id="test_subject" name="test_subject" data-autoresize></textarea>
</div>
</div>
<p class="help-block">Set custom JSON headers sent to the webhook.</p>
@ -379,7 +379,7 @@
<label for="test_body">JSON Data</label>
<div class="row">
<div class="col-md-12">
<textarea class="form-control" id="test_body" name="test_body" autocomplete="off" data-autoresize></textarea>
<textarea class="form-control" id="test_body" name="test_body" data-autoresize></textarea>
</div>
</div>
<p class="help-block">Set custom JSON data sent to the webhook.</p>
@ -389,7 +389,7 @@
<label for="test_subject">Subject Line</label>
<div class="row">
<div class="col-md-12">
<input class="form-control" type="text" id="test_subject" name="test_subject" value="Tautulli" autocomplete="off">
<input class="form-control" type="text" id="test_subject" name="test_subject" value="Tautulli">
</div>
</div>
<p class="help-block">Set a custom subject line.</p>
@ -398,7 +398,7 @@
<label for="test_body">Message Body</label>
<div class="row">
<div class="col-md-12">
<textarea class="form-control" id="test_body" name="test_body" autocomplete="off" data-autoresize>Test Notification</textarea>
<textarea class="form-control" id="test_body" name="test_body" data-autoresize>Test Notification</textarea>
</div>
</div>
<p class="help-block">Set a custom body.</p>

View file

@ -19,20 +19,7 @@ DOCUMENTATION :: END
% else:
${notifier['agent_label']} &nbsp;<span class="friendly_name">(${notifier['id']})</span>
% endif
<span class="toggle-right friendly_name">
% if notifier['last_triggered']:
<% icon, icon_tooltip = ('fa-check', 'Success') if notifier['last_success'] else ('fa-times', 'Failed') %>
<span id="notifier-last_triggered-${notifier['id']}">
<script>
$("#notifier-last_triggered-${notifier['id']}").html(
moment("${notifier['last_triggered']}", "X").fromNow() + ' <i class="fa fa-lg fa-fw ${icon}" data-toggle="tooltip" data-placement="top" title="${icon_tooltip}"></i>'
)
</script>
</span>
% else:
never
<i class="fa fa-lg fa-fw fa-minus"></i>
% endif
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
</span>
</li>
% endfor

View file

@ -36,7 +36,7 @@ DOCUMENTATION :: END
%>
% if data:
<div class="dashboard-recent-media-row">
<div id="recently-added-row-scroller">
<div id="recently-added-row-scroller" style="left: 0;">
<ul class="dashboard-recent-media list-unstyled">
% for item in data:
<div class="dashboard-recent-media-instance">

View file

@ -13,6 +13,8 @@ DOCUMENTATION :: END
import datetime
import plexpy
from plexpy import common, helpers
scheduled_jobs = [j.id for j in plexpy.SCHED.get_jobs()]
%>
<table class="config-scheduler-table small-muted">
@ -27,15 +29,16 @@ DOCUMENTATION :: END
</thead>
<tbody>
% for job, job_type in common.SCHEDULER_LIST.items():
% if job in scheduled_jobs:
<%
sched_job = plexpy.SCHED.get_job(job)
now = datetime.datetime.now(sched_job.next_run_time.tzinfo)
%>
% if sched_job:
<tr>
<td>${sched_job.id}</td>
<td><i class="fa fa-sm fa-fw fa-check"></i> Active</td>
<td>${helpers.format_timedelta_Hms(sched_job.trigger.interval)}</td>
<td>${helpers.format_timedelta_Hms(sched_job.next_run_time - datetime.datetime.now(sched_job.next_run_time.tzinfo))}</td>
<td>${helpers.format_timedelta_Hms(sched_job.next_run_time - now)}</td>
<td>${sched_job.next_run_time.astimezone(plexpy.SYS_TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')}</td>
</tr>
% elif job_type == 'websocket' and plexpy.WS_CONNECTED:

View file

@ -132,6 +132,12 @@
</label>
<p class="help-block">Change the "<em>Play by day of week</em>" graph to start on Monday. Default is start on Sunday.</p>
</div>
<div class="checkbox advanced-setting">
<label>
<input type="checkbox" id="group_history_tables" name="group_history_tables" value="1" ${config['group_history_tables']}> Group Play History
</label>
<p class="help-block">Group play history for the same item and user as a single entry when progress is less than the watched percent.</p>
</div>
<div class="checkbox advanced-setting">
<label>
<input type="checkbox" id="history_table_activity" name="history_table_activity" value="1" ${config['history_table_activity']}> Current Activity in History Tables
@ -221,25 +227,6 @@
</div>
<p class="help-block">Decide whether to use end credits markers to determine the 'watched' state of video items. When markers are not available the selected threshold percentage will be used.</p>
</div>
<div class="checkbox advanced-setting">
<label>
<input type="checkbox" id="group_history_tables" name="group_history_tables" value="1" ${config['group_history_tables']}> Group Play History
</label>
<p class="help-block">Group play history for the same item and user as a single entry when progress is less than the watched percent.</p>
</div>
<div class="form-group advanced-setting">
<label>Regroup Play History</label>
<p class="help-block">
Fix grouping of play history in the database.<br />
</p>
<div class="row">
<div class="col-md-4">
<div class="btn-group">
<button class="btn btn-form" type="button" id="regroup_history">Regroup</button>
</div>
</div>
</div>
</div>
<div class="form-group advanced-setting">
<label>Flush Temporary Sessions</label>
<p class="help-block">
@ -676,6 +663,27 @@
</div>
</div>
<div class="checkbox advanced-setting">
<label>
<input type="checkbox" name="anon_redirect_dynamic" id="anon_redirect_dynamic" value="1" ${config['anon_redirect_dynamic']} /> Use Dynamic Anonymous Redirect Service
</label>
<p class="help-block">
Allow Tautulli to use the dynamic anonymous redirect service listed <a href="${anon_url('https://tautulli.com/anonymizer.txt')}" target="_blank" rel="noreferrer">here</a>.
Disable to specify your own service.
</p>
</div>
<div id="anon_redirect_options">
<div class="form-group advanced-setting">
<label for="anon_redirect">Anonymous Redirect Service</label>
<div class="row">
<div class="col-md-4">
<input type="text" class="form-control" id="anon_redirect" name="anon_redirect" value="${config['anon_redirect']}" size="30">
</div>
</div>
<p class="help-block">Backlink protection via anonymizer service, must end in "?". Leave blank to disable.</p>
</div>
</div>
<div class="padded-header">
<h3>Authentication</h3>
</div>
@ -767,6 +775,7 @@
data-identifier="${config['pms_identifier']}"
data-ip="${config['pms_ip']}"
data-port="${config['pms_port']}"
data-local="${int(not int(config['pms_is_remote']))}"
data-ssl="${config['pms_ssl']}"
data-is_cloud="${config['pms_is_cloud']}"
data-label="${config['pms_name'] or 'Local'}"
@ -799,6 +808,13 @@
</label>
<p class="help-block">Connect to your Plex server using HTTPS if you have <a href="${anon_url('https://support.plex.tv/articles/206225077-how-to-use-secure-server-connections')}" target="_blank" rel="noreferrer">secure connections</a> enabled.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle pms-settings" data-id="pms_is_remote" value="1" ${checked(config['pms_is_remote'])}> Remote Server
<input type="hidden" id="pms_is_remote" name="pms_is_remote" value="${config['pms_is_remote']}">
</label>
<p class="help-block">Check this if your Plex Server is not on the same local network as Tautulli.</p>
</div>
<div class="form-group">
<label for="pms_url">Plex Server URL</label>
<div class="row">
@ -1249,7 +1265,7 @@
</div>
<p class="help-block">
Add a new notification agent, or configure an existing notification agent by clicking on the item below.
Add a new notification agent, or configure an existing notification agent by clicking the settings icon on the right.
</p>
<p class="help-block">
Please see the <a href="${anon_url('https://github.com/%s/%s/wiki/Notification-Agents-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">Notification Agents Guide</a> for instructions on setting up each notification agent.
@ -1269,7 +1285,7 @@
</div>
<p class="help-block">
Add a new newsletter agent, or configure an existing newsletter agent by clicking on the item below.
Add a new newsletter agent, or configure an existing newsletter agent by clicking the settings icon on the right.
</p>
<p class="help-block settings-warning" id="newsletter_upload_warning">
Warning: The <a data-tab-destination="3rd_party_apis" data-target="notify_upload_posters">Image Hosting</a> setting must be enabled for images to display on the newsletter.</span>
@ -1601,7 +1617,7 @@
</div>
<div class="form-group">
<label>Registered Devices</label>
<p class="help-block">Register a new device using a QR code, or configure an existing device by clicking on the item below.</p>
<p class="help-block">Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.</p>
<p id="app_api_msg" style="color: #eb8600;">Warning: The API must be enabled under <a data-tab-destination="web_interface" data-target="api_enabled">Web Interface</a> to use the app.</p>
<br />
<div class="row">
@ -1894,21 +1910,6 @@
<p><strong style="color: #fff;">Example:</strong></p>
<pre>{media_type} --> movie
{media_type!c} --> Movie</pre>
</div>
<div>
<h4>Time Formats</h4>
</div>
<div style="padding-bottom: 10px;">
<p class="help-block">
Notification parameters which are "in date format" or "in time format" can be formatted using the
<a href="javascript:void(0)" data-target="#dateTimeOptionsModal" data-toggle="modal">Date & Time Format Options</a>
by adding a <span class="inline-pre">:format</span> specifier.
If no format is specified, the default Date Format and Time Format under Settings > General will be used.
</p>
<p><strong style="color: #fff;">Example:</strong></p>
<pre>{started_datestamp:ddd, MMMM DD, YYYY} --> Mon, December 25, 2023
{stopped_timestamp:h:mm a} --> 9:56 pm
{duration_time:HH:mm:ss} --> 01:42:20</pre>
</div>
<div>
<h4>List Slicing</h4>
@ -1980,7 +1981,7 @@ Rating: {rating}/10 --> Rating: /10
<li>Evaluation</li>
<li>Parameter</li>
<li>Case Modifier</li>
<li>Time Formats / List Slicing</li>
<li>List Slicing</li>
<li>Suffix</li>
</ol>
<p><strong style="color: #fff;">Example:</strong></p>
@ -2155,6 +2156,7 @@ Rating: {rating}/10 --> Rating: /10
<script src="${http_root}js/parsley.min.js"></script>
<script src="${http_root}js/Sortable.min.js"></script>
<script src="${http_root}js/jquery.inputaffix.min.js"></script>
<script src="${http_root}js/kjua.min.js"></script>
<script>
function getConfigurationTable() {
$.ajax({
@ -2386,6 +2388,7 @@ $(document).ready(function() {
initConfigCheckbox('#api_enabled');
initConfigCheckbox('#enable_https');
initConfigCheckbox('#anon_redirect_dynamic', null, true);
initConfigCheckbox('#https_create_cert');
initConfigCheckbox('#check_github');
initConfigCheckbox('#monitor_pms_updates');
@ -2481,12 +2484,6 @@ $(document).ready(function() {
confirmAjaxCall(url, msg);
});
$("#regroup_history").click(function () {
var msg = 'Are you sure you want to regroup play history in the database?<br /><br /><strong>This make take a long time for large databases.<br />Regrouping will continue in the background.</strong>';
var url = 'regroup_history';
confirmAjaxCall(url, msg);
});
$("#delete_temp_sessions").click(function () {
var msg = 'Are you sure you want to flush the temporary sessions?<br /><br /><strong>This will reset all currently active sessions.</strong>';
var url = 'delete_temp_sessions';
@ -2588,6 +2585,7 @@ $(document).ready(function() {
return '<div data-identifier="' + item.clientIdentifier +
'" data-ip="' + item.ip +
'" data-port="' + item.port +
'" data-local="' + item.local +
'" data-ssl="' + item.httpsRequired +
'" data-is_cloud="' + item.is_cloud +
'" data-label="' + item.label + '">' +
@ -2601,6 +2599,7 @@ $(document).ready(function() {
return '<div data-identifier="' + item.clientIdentifier +
'" data-ip="' + item.ip +
'" data-port="' + item.port +
'" data-local="' + item.local +
'" data-ssl="' + item.httpsRequired +
'" data-is_cloud="' + item.is_cloud +
'" data-label="' + item.label + '">' +
@ -2623,6 +2622,7 @@ $(document).ready(function() {
var identifier = $(pms_ip_selected).data('identifier');
var ip = $(pms_ip_selected).data('ip');
var port = $(pms_ip_selected).data('port');
var local = $(pms_ip_selected).data('local');
var ssl = $(pms_ip_selected).data('ssl');
var is_cloud = $(pms_ip_selected).data('is_cloud');
var value = $(pms_ip_selected).data('value');
@ -2630,6 +2630,8 @@ $(document).ready(function() {
$("#pms_identifier").val(identifier !== 'undefined' ? identifier : '');
$('#pms_ip').val(ip !== 'undefined' ? ip : value);
$('#pms_port').val(port !== 'undefined' ? port : 32400);
$('#pms_is_remote_checkbox').prop('checked', (local !== 'undefined' && local === 0));
$('#pms_is_remote').val(local !== 'undefined' && local === 0 ? 1 : 0);
$('#pms_ssl_checkbox').prop('checked', (ssl !== 'undefined' && ssl === 1));
$('#pms_ssl').val(ssl !== 'undefined' && ssl === 1 ? 1 : 0);
$('#pms_is_cloud').val(is_cloud !== 'undefined' && is_cloud === true ? 1 : 0);
@ -2667,6 +2669,7 @@ $(document).ready(function() {
var pms_port = $("#pms_port").val();
var pms_identifier = $("#pms_identifier").val();
var pms_ssl = $("#pms_ssl").val();
var pms_is_remote = $("#pms_is_remote").val();
var pms_url_manual = $("#pms_url_manual").is(':checked') ? 1 : 0;
if (($("#pms_ip").val() !== '') || ($("#pms_port").val() !== '')) {
@ -2678,6 +2681,7 @@ $(document).ready(function() {
hostname: pms_ip,
port: pms_port,
ssl: pms_ssl,
remote: pms_is_remote,
manual: pms_url_manual,
get_url: true,
test_websocket: true

View file

@ -68,14 +68,14 @@ DOCUMENTATION :: END
<table class="stream-info" style="margin-top: 0;">
<thead>
<tr>
<th></th>
<th class="heading">
Source Details
<th>
</th>
<th><i class="fa fa-long-arrow-right"></i></th>
<th class="heading">
Stream Details
</th>
<th class="heading">
Source Details
</th>
</tr>
</thead>
</table>
@ -85,46 +85,38 @@ DOCUMENTATION :: END
<th>
Media
</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>Bitrate</td>
<td>${data['bitrate']} ${'kbps' if data['bitrate'] else ''}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''}</td>
<td>${data['bitrate']} ${'kbps' if data['bitrate'] else ''}</td>
</tr>
% if data['media_type'] != 'track':
<tr>
<td>Resolution</td>
<td>${data['video_full_resolution']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_full_resolution']}</td>
<td>${data['video_full_resolution']}</td>
</tr>
% endif
<tr>
<td>Quality</td>
<td>-</td>
<td></td>
<td>${data['quality_profile']}</td>
<td>-</td>
</tr>
% if data['optimized_version'] == 1:
<tr>
<td>Optimized Version</td>
<td>${data['optimized_version_profile']}<br>(${data['optimized_version_title']})</td>
<td></td>
<td>-</td>
<td>${data['optimized_version_profile']}<br>(${data['optimized_version_title']})</td>
</tr>
% endif
% if data['synced_version'] == 1:
<tr>
<td>Synced Version</td>
<td>${data['synced_version_profile']}</td>
<td></td>
<td>-</td>
<td>${data['synced_version_profile']}</td>
</tr>
% endif
</tbody>
@ -135,8 +127,6 @@ DOCUMENTATION :: END
<th>
Container
</th>
<th></th>
<th></th>
<th>
${data['stream_container_decision']}
</th>
@ -145,9 +135,8 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Container</td>
<td>${data['container'].upper()}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_container'].upper()}</td>
<td>${data['container'].upper()}</td>
</tr>
</tbody>
</table>
@ -158,8 +147,6 @@ DOCUMENTATION :: END
<th>
Video
</th>
<th></th>
<th></th>
<th>
${data['stream_video_decision']}
</th>
@ -168,45 +155,38 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Codec</td>
<td>${data['video_codec'].upper()} ${'(HW)' if data['transcode_hw_decoding'] else ''}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_codec'].upper()} ${'(HW)' if data['transcode_hw_encoding'] else ''}</td>
<td>${data['video_codec'].upper()} ${'(HW)' if data['transcode_hw_decoding'] else ''}</td>
</tr>
<tr>
<td>Bitrate</td>
<td>${data['video_bitrate']} ${'kbps' if data['video_bitrate'] else ''}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''}</td>
<td>${data['video_bitrate']} ${'kbps' if data['video_bitrate'] else ''}</td>
</tr>
<tr>
<td>Width</td>
<td>${data['video_width']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_width']}</td>
<td>${data['video_width']}</td>
</tr>
<tr>
<td>Height</td>
<td>${data['video_height']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_height']}</td>
<td>${data['video_height']}</td>
</tr>
<tr>
<td>Framerate</td>
<td>${data['video_framerate']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_framerate']}</td>
<td>${data['video_framerate']}</td>
</tr>
<tr>
<td>Dynamic Range</td>
<td>${data['video_dynamic_range']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_dynamic_range']}</td>
<td>${data['video_dynamic_range']}</td>
</tr>
<tr>
<td>Aspect Ratio</td>
<td>${data['aspect_ratio']}</td>
<td></td>
<td>-</td>
<td>${data['aspect_ratio']}</td>
</tr>
</tbody>
</table>
@ -217,8 +197,6 @@ DOCUMENTATION :: END
<th>
Audio
</th>
<th></th>
<th></th>
<th>
${data['stream_audio_decision']}
</th>
@ -227,27 +205,23 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Codec</td>
<td>${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())}</td>
<td>${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())}</td>
</tr>
<tr>
<td>Bitrate</td>
<td>${data['audio_bitrate']} ${'kbps' if data['audio_bitrate'] else ''}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''}</td>
<td>${data['audio_bitrate']} ${'kbps' if data['audio_bitrate'] else ''}</td>
</tr>
<tr>
<td>Channels</td>
<td>${data['audio_channels']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_audio_channels']}</td>
<td>${data['audio_channels']}</td>
</tr>
<tr>
<td>Language</td>
<td>${data['audio_language'] or 'Unknown'}</td>
<td></td>
<td>-</td>
<td>${data['audio_language'] or 'Unknown'}</td>
</tr>
</tbody>
@ -259,8 +233,6 @@ DOCUMENTATION :: END
<th>
Subtitles
</th>
<th></th>
<th></th>
<th>
${'direct play' if data['stream_subtitle_decision'] not in ('transcode', 'copy', 'burn') else data['stream_subtitle_decision']}
</th>
@ -269,22 +241,19 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Codec</td>
<td>${data['subtitle_codec'].upper()}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_subtitle_codec'].upper() or '-'}</td>
<td>${data['subtitle_codec'].upper()}</td>
</tr>
<tr>
<td>Language</td>
<td>${data['subtitle_language'] or 'Unknown'}</td>
<td></td>
<td>-</td>
<td>${data['subtitle_language'] or 'Unknown'}</td>
</tr>
% if data['subtitle_forced']:
<tr>
<td>Forced</td>
<td>${bool(data['subtitle_forced'])}</td>
<td></td>
<td>-</td>
<td>${bool(data['subtitle_forced'])}</td>
</tr>
% endif
</tbody>

View file

@ -30,7 +30,7 @@
<div class="iframe-container">
<iframe class="iframe" allowfullscreen="true" id="support-iframe" data-name="Tautulli-Support" data-src="https://support.tautulli.com"
sandbox="allow-presentation allow-forms allow-same-origin allow-pointer-lock allow-scripts allow-popups allow-modals allow-top-navigation"
style="display: none;" rel="noreferrer">
style="display: none;">
</iframe>
<div class="iframe-overlay">
<div class="iframe-button-container">

View file

@ -76,6 +76,7 @@ DOCUMENTATION :: END
% if _session['user_group'] == 'admin':
<li><a id="nav-tabs-export" href="#tabs-export" role="tab" data-toggle="tab">Export</a></li>
% 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-tautullilogins" href="#tabs-tautullilogins" role="tab" data-toggle="tab">Tautulli Logins</a></li>
</ul>
@ -125,10 +126,10 @@ DOCUMENTATION :: END
<div class="table-card-header">
<ul class="nav nav-header nav-dashboard pull-right">
<li>
<a href="#" id="recently-watched-page-left" class="paginate-watched btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
<a href="#" id="recently-watched-page-left" class="paginate btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
</li>
<li>
<a href="#" id="recently-watched-page-right" class="paginate-watched btn-gray" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
<a href="#" id="recently-watched-page-right" class="paginate btn-gray" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
</li>
</ul>
<div class="header-bar">
@ -211,7 +212,7 @@ DOCUMENTATION :: END
<th align="left" id="started">Started</th>
<th align="left" id="paused_counter">Paused</th>
<th align="left" id="stopped">Stopped</th>
<th align="left" id="play_duration">Duration</th>
<th align="left" id="duration">Duration</th>
<th align="left" id="percent_complete"></th>
</tr>
</thead>
@ -315,6 +316,57 @@ DOCUMENTATION :: END
</div>
</div>
% 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 class="container-fluid">
<div class="row">
@ -590,6 +642,12 @@ DOCUMENTATION :: END
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() {
sync_table.ajax.reload();
});
@ -666,14 +724,52 @@ DOCUMENTATION :: END
},
complete: function(xhr, status) {
$("#user-recently-watched").html(xhr.responseText);
highlightScrollerButton("#recently-watched");
paginateScroller("#recently-watched", ".paginate-watched");
highlightWatchedScrollerButton();
}
});
}
recentlyWatched();
function highlightWatchedScrollerButton() {
var scroller = $("#recently-watched-row-scroller");
var numElems = scroller.find("li").length;
scroller.width(numElems * 175);
if (scroller.width() > $("#user-recently-watched").width()) {
$("#recently-watched-page-right").removeClass("disabled");
} else {
$("#recently-watched-page-right").addClass("disabled");
}
}
$(window).resize(function() {
highlightWatchedScrollerButton();
});
var leftTotal = 0;
$(".paginate").click(function (e) {
e.preventDefault();
var scroller = $("#recently-watched-row-scroller");
var containerWidth = $("#user-recently-watched").width();
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
leftTotal = Math.max(Math.min(leftTotal + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotal }, 250);
if (leftTotal == 0) {
$("#recently-watched-page-left").addClass("disabled").blur();
} else {
$("#recently-watched-page-left").removeClass("disabled");
}
if (leftTotal == leftMax) {
$("#recently-watched-page-right").addClass("disabled").blur();
} else {
$("#recently-watched-page-right").removeClass("disabled");
}
});
$(document).ready(function () {
// Javascript to enable link to tab
var hash = document.location.hash;

View file

@ -31,7 +31,7 @@ DOCUMENTATION :: END
from plexpy.helpers import page, short_season
%>
<div class="dashboard-recent-media-row">
<div id="recently-watched-row-scroller">
<div id="recently-watched-row-scroller" style="left: 0;">
<ul class="dashboard-recent-media list-unstyled">
% for item in data:
<li>

View file

@ -12,7 +12,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
<meta name="referrer" content="no-referrer">
<link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet">
<link href="${http_root}css/bootstrap-wizard.css" rel="stylesheet">
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
@ -135,6 +134,7 @@
data-identifier="${config['pms_identifier']}"
data-ip="${config['pms_ip']}"
data-port="${config['pms_port']}"
data-local="${int(not int(config['pms_is_remote']))}"
data-ssl="${config['pms_ssl']}"
data-is_cloud="${config['pms_is_cloud']}"
data-label="${config['pms_name'] or 'Local'}"
@ -150,7 +150,7 @@
<div class="col-xs-3">
<input type="text" class="form-control pms-settings" name="pms_port" id="pms_port" placeholder="32400" value="${config['pms_port']}" required>
</div>
<div class="col-xs-9">
<div class="col-xs-4">
<div class="checkbox">
<label>
<input type="checkbox" id="pms_ssl_checkbox" class="checkbox-toggle pms-settings" data-id="pms_ssl" value="1" ${helpers.checked(config['pms_ssl'])}> Use Secure Connection
@ -158,6 +158,14 @@
</label>
</div>
</div>
<div class="col-xs-4">
<div class="checkbox">
<label>
<input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle pms-settings" data-id="pms_is_remote" value="1" ${helpers.checked(config['pms_is_remote'])}> Remote Server
<input type="hidden" id="pms_is_remote" name="pms_is_remote" value="${config['pms_is_remote']}">
</label>
</div>
</div>
</div>
</div>
<input type="hidden" id="pms_valid" data-validate="validatePMSip" value="">
@ -382,6 +390,7 @@ $(document).ready(function() {
return '<div data-identifier="' + item.clientIdentifier +
'" data-ip="' + item.ip +
'" data-port="' + item.port +
'" data-local="' + item.local +
'" data-ssl="' + item.httpsRequired +
'" data-is_cloud="' + item.is_cloud +
'" data-label="' + item.label + '">' +
@ -395,6 +404,7 @@ $(document).ready(function() {
return '<div data-identifier="' + item.clientIdentifier +
'" data-ip="' + item.ip +
'" data-port="' + item.port +
'" data-local="' + item.local +
'" data-ssl="' + item.httpsRequired +
'" data-is_cloud="' + item.is_cloud +
'" data-label="' + item.label + '">' +
@ -417,6 +427,7 @@ $(document).ready(function() {
var identifier = $(pms_ip_selected).data('identifier');
var ip = $(pms_ip_selected).data('ip');
var port = $(pms_ip_selected).data('port');
var local = $(pms_ip_selected).data('local');
var ssl = $(pms_ip_selected).data('ssl');
var is_cloud = $(pms_ip_selected).data('is_cloud');
var value = $(pms_ip_selected).data('value');
@ -427,15 +438,19 @@ $(document).ready(function() {
$("#pms_identifier").val(identifier !== 'undefined' ? identifier : '');
$('#pms_ip').val(ip !== 'undefined' ? ip : value);
$('#pms_port').val(port !== 'undefined' ? port : 32400);
$('#pms_is_remote_checkbox').prop('checked', (local !== 'undefined' && local === 0));
$('#pms_is_remote').val(local !== 'undefined' && local === 0 ? 1 : 0);
$('#pms_ssl_checkbox').prop('checked', (ssl !== 'undefined' && ssl === 1));
$('#pms_ssl').val(ssl !== 'undefined' && ssl === 1 ? 1 : 0);
$('#pms_is_cloud').val(is_cloud !== 'undefined' && is_cloud === true ? 1 : 0);
if (is_cloud === true) {
$('#pms_port').prop('readonly', true);
$('#pms_is_remote_checkbox').prop('disabled', true);
$('#pms_ssl_checkbox').prop('disabled', true);
} else {
$('#pms_port').prop('readonly', false);
$('#pms_is_remote_checkbox').prop('disabled', false);
$('#pms_ssl_checkbox').prop('disabled', false);
}
},
@ -472,6 +487,7 @@ $(document).ready(function() {
var pms_port = $("#pms_port").val().trim();
var pms_identifier = $("#pms_identifier").val();
var pms_ssl = $("#pms_ssl").val();
var pms_is_remote = $("#pms_is_remote").val();
if ((pms_ip !== '') || (pms_port !== '')) {
$("#pms-verify-status").html('<i class="fa fa-refresh fa-spin"></i>&nbsp; Verifying server...');
$('#pms-verify-status').fadeIn('fast');
@ -481,7 +497,8 @@ $(document).ready(function() {
hostname: pms_ip,
port: pms_port,
identifier: pms_identifier,
ssl: pms_ssl
ssl: pms_ssl,
remote: pms_is_remote
},
cache: true,
async: true,

View file

@ -1,396 +0,0 @@
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

@ -1,147 +0,0 @@
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])

608
lib/appdirs.py Normal file
View file

@ -0,0 +1,608 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright (c) 2005-2010 ActiveState Software Inc.
# Copyright (c) 2013 Eddy Petrișor
"""Utilities for determining application-specific dirs.
See <http://github.com/ActiveState/appdirs> for details and usage.
"""
# Dev Notes:
# - MSDN on where to store app data files:
# http://support.microsoft.com/default.aspx?scid=kb;en-us;310294#XSLTH3194121123120121120120
# - Mac OS X: http://developer.apple.com/documentation/MacOSX/Conceptual/BPFileSystem/index.html
# - XDG spec for Un*x: http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
__version__ = "1.4.4"
__version_info__ = tuple(int(segment) for segment in __version__.split("."))
import sys
import os
PY3 = sys.version_info[0] == 3
if PY3:
unicode = str
if sys.platform.startswith('java'):
import platform
os_name = platform.java_ver()[3][0]
if os_name.startswith('Windows'): # "Windows XP", "Windows 7", etc.
system = 'win32'
elif os_name.startswith('Mac'): # "Mac OS X", etc.
system = 'darwin'
else: # "Linux", "SunOS", "FreeBSD", etc.
# Setting this to "linux2" is not ideal, but only Windows or Mac
# are actually checked for and the rest of the module expects
# *sys.platform* style strings.
system = 'linux2'
else:
system = sys.platform
def user_data_dir(appname=None, appauthor=None, version=None, roaming=False):
r"""Return full path to the user-specific data dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"roaming" (boolean, default False) can be set True to use the Windows
roaming appdata directory. That means that for users on a Windows
network setup for roaming profiles, this user data will be
sync'd on login. See
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
for a discussion of issues.
Typical user data directories are:
Mac OS X: ~/Library/Application Support/<AppName>
Unix: ~/.local/share/<AppName> # or in $XDG_DATA_HOME, if defined
Win XP (not roaming): C:\Documents and Settings\<username>\Application Data\<AppAuthor>\<AppName>
Win XP (roaming): C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>
Win 7 (not roaming): C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>
Win 7 (roaming): C:\Users\<username>\AppData\Roaming\<AppAuthor>\<AppName>
For Unix, we follow the XDG spec and support $XDG_DATA_HOME.
That means, by default "~/.local/share/<AppName>".
"""
if system == "win32":
if appauthor is None:
appauthor = appname
const = roaming and "CSIDL_APPDATA" or "CSIDL_LOCAL_APPDATA"
path = os.path.normpath(_get_win_folder(const))
if appname:
if appauthor is not False:
path = os.path.join(path, appauthor, appname)
else:
path = os.path.join(path, appname)
elif system == 'darwin':
path = os.path.expanduser('~/Library/Application Support/')
if appname:
path = os.path.join(path, appname)
else:
path = os.getenv('XDG_DATA_HOME', os.path.expanduser("~/.local/share"))
if appname:
path = os.path.join(path, appname)
if appname and version:
path = os.path.join(path, version)
return path
def site_data_dir(appname=None, appauthor=None, version=None, multipath=False):
r"""Return full path to the user-shared data dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"multipath" is an optional parameter only applicable to *nix
which indicates that the entire list of data dirs should be
returned. By default, the first item from XDG_DATA_DIRS is
returned, or '/usr/local/share/<AppName>',
if XDG_DATA_DIRS is not set
Typical site data directories are:
Mac OS X: /Library/Application Support/<AppName>
Unix: /usr/local/share/<AppName> or /usr/share/<AppName>
Win XP: C:\Documents and Settings\All Users\Application Data\<AppAuthor>\<AppName>
Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
Win 7: C:\ProgramData\<AppAuthor>\<AppName> # Hidden, but writeable on Win 7.
For Unix, this is using the $XDG_DATA_DIRS[0] default.
WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
"""
if system == "win32":
if appauthor is None:
appauthor = appname
path = os.path.normpath(_get_win_folder("CSIDL_COMMON_APPDATA"))
if appname:
if appauthor is not False:
path = os.path.join(path, appauthor, appname)
else:
path = os.path.join(path, appname)
elif system == 'darwin':
path = os.path.expanduser('/Library/Application Support')
if appname:
path = os.path.join(path, appname)
else:
# XDG default for $XDG_DATA_DIRS
# only first, if multipath is False
path = os.getenv('XDG_DATA_DIRS',
os.pathsep.join(['/usr/local/share', '/usr/share']))
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
if appname:
if version:
appname = os.path.join(appname, version)
pathlist = [os.sep.join([x, appname]) for x in pathlist]
if multipath:
path = os.pathsep.join(pathlist)
else:
path = pathlist[0]
return path
if appname and version:
path = os.path.join(path, version)
return path
def user_config_dir(appname=None, appauthor=None, version=None, roaming=False):
r"""Return full path to the user-specific config dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"roaming" (boolean, default False) can be set True to use the Windows
roaming appdata directory. That means that for users on a Windows
network setup for roaming profiles, this user data will be
sync'd on login. See
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
for a discussion of issues.
Typical user config directories are:
Mac OS X: same as user_data_dir
Unix: ~/.config/<AppName> # or in $XDG_CONFIG_HOME, if defined
Win *: same as user_data_dir
For Unix, we follow the XDG spec and support $XDG_CONFIG_HOME.
That means, by default "~/.config/<AppName>".
"""
if system in ["win32", "darwin"]:
path = user_data_dir(appname, appauthor, None, roaming)
else:
path = os.getenv('XDG_CONFIG_HOME', os.path.expanduser("~/.config"))
if appname:
path = os.path.join(path, appname)
if appname and version:
path = os.path.join(path, version)
return path
def site_config_dir(appname=None, appauthor=None, version=None, multipath=False):
r"""Return full path to the user-shared data dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"multipath" is an optional parameter only applicable to *nix
which indicates that the entire list of config dirs should be
returned. By default, the first item from XDG_CONFIG_DIRS is
returned, or '/etc/xdg/<AppName>', if XDG_CONFIG_DIRS is not set
Typical site config directories are:
Mac OS X: same as site_data_dir
Unix: /etc/xdg/<AppName> or $XDG_CONFIG_DIRS[i]/<AppName> for each value in
$XDG_CONFIG_DIRS
Win *: same as site_data_dir
Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
For Unix, this is using the $XDG_CONFIG_DIRS[0] default, if multipath=False
WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
"""
if system in ["win32", "darwin"]:
path = site_data_dir(appname, appauthor)
if appname and version:
path = os.path.join(path, version)
else:
# XDG default for $XDG_CONFIG_DIRS
# only first, if multipath is False
path = os.getenv('XDG_CONFIG_DIRS', '/etc/xdg')
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
if appname:
if version:
appname = os.path.join(appname, version)
pathlist = [os.sep.join([x, appname]) for x in pathlist]
if multipath:
path = os.pathsep.join(pathlist)
else:
path = pathlist[0]
return path
def user_cache_dir(appname=None, appauthor=None, version=None, opinion=True):
r"""Return full path to the user-specific cache dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"opinion" (boolean) can be False to disable the appending of
"Cache" to the base app data dir for Windows. See
discussion below.
Typical user cache directories are:
Mac OS X: ~/Library/Caches/<AppName>
Unix: ~/.cache/<AppName> (XDG default)
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Cache
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Cache
On Windows the only suggestion in the MSDN docs is that local settings go in
the `CSIDL_LOCAL_APPDATA` directory. This is identical to the non-roaming
app data dir (the default returned by `user_data_dir` above). Apps typically
put cache data somewhere *under* the given dir here. Some examples:
...\Mozilla\Firefox\Profiles\<ProfileName>\Cache
...\Acme\SuperApp\Cache\1.0
OPINION: This function appends "Cache" to the `CSIDL_LOCAL_APPDATA` value.
This can be disabled with the `opinion=False` option.
"""
if system == "win32":
if appauthor is None:
appauthor = appname
path = os.path.normpath(_get_win_folder("CSIDL_LOCAL_APPDATA"))
if appname:
if appauthor is not False:
path = os.path.join(path, appauthor, appname)
else:
path = os.path.join(path, appname)
if opinion:
path = os.path.join(path, "Cache")
elif system == 'darwin':
path = os.path.expanduser('~/Library/Caches')
if appname:
path = os.path.join(path, appname)
else:
path = os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
if appname:
path = os.path.join(path, appname)
if appname and version:
path = os.path.join(path, version)
return path
def user_state_dir(appname=None, appauthor=None, version=None, roaming=False):
r"""Return full path to the user-specific state dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"roaming" (boolean, default False) can be set True to use the Windows
roaming appdata directory. That means that for users on a Windows
network setup for roaming profiles, this user data will be
sync'd on login. See
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
for a discussion of issues.
Typical user state directories are:
Mac OS X: same as user_data_dir
Unix: ~/.local/state/<AppName> # or in $XDG_STATE_HOME, if defined
Win *: same as user_data_dir
For Unix, we follow this Debian proposal <https://wiki.debian.org/XDGBaseDirectorySpecification#state>
to extend the XDG spec and support $XDG_STATE_HOME.
That means, by default "~/.local/state/<AppName>".
"""
if system in ["win32", "darwin"]:
path = user_data_dir(appname, appauthor, None, roaming)
else:
path = os.getenv('XDG_STATE_HOME', os.path.expanduser("~/.local/state"))
if appname:
path = os.path.join(path, appname)
if appname and version:
path = os.path.join(path, version)
return path
def user_log_dir(appname=None, appauthor=None, version=None, opinion=True):
r"""Return full path to the user-specific log dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"opinion" (boolean) can be False to disable the appending of
"Logs" to the base app data dir for Windows, and "log" to the
base cache dir for Unix. See discussion below.
Typical user log directories are:
Mac OS X: ~/Library/Logs/<AppName>
Unix: ~/.cache/<AppName>/log # or under $XDG_CACHE_HOME if defined
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Logs
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Logs
On Windows the only suggestion in the MSDN docs is that local settings
go in the `CSIDL_LOCAL_APPDATA` directory. (Note: I'm interested in
examples of what some windows apps use for a logs dir.)
OPINION: This function appends "Logs" to the `CSIDL_LOCAL_APPDATA`
value for Windows and appends "log" to the user cache dir for Unix.
This can be disabled with the `opinion=False` option.
"""
if system == "darwin":
path = os.path.join(
os.path.expanduser('~/Library/Logs'),
appname)
elif system == "win32":
path = user_data_dir(appname, appauthor, version)
version = False
if opinion:
path = os.path.join(path, "Logs")
else:
path = user_cache_dir(appname, appauthor, version)
version = False
if opinion:
path = os.path.join(path, "log")
if appname and version:
path = os.path.join(path, version)
return path
class AppDirs(object):
"""Convenience wrapper for getting application dirs."""
def __init__(self, appname=None, appauthor=None, version=None,
roaming=False, multipath=False):
self.appname = appname
self.appauthor = appauthor
self.version = version
self.roaming = roaming
self.multipath = multipath
@property
def user_data_dir(self):
return user_data_dir(self.appname, self.appauthor,
version=self.version, roaming=self.roaming)
@property
def site_data_dir(self):
return site_data_dir(self.appname, self.appauthor,
version=self.version, multipath=self.multipath)
@property
def user_config_dir(self):
return user_config_dir(self.appname, self.appauthor,
version=self.version, roaming=self.roaming)
@property
def site_config_dir(self):
return site_config_dir(self.appname, self.appauthor,
version=self.version, multipath=self.multipath)
@property
def user_cache_dir(self):
return user_cache_dir(self.appname, self.appauthor,
version=self.version)
@property
def user_state_dir(self):
return user_state_dir(self.appname, self.appauthor,
version=self.version)
@property
def user_log_dir(self):
return user_log_dir(self.appname, self.appauthor,
version=self.version)
#---- internal support stuff
def _get_win_folder_from_registry(csidl_name):
"""This is a fallback technique at best. I'm not sure if using the
registry for this guarantees us the correct answer for all CSIDL_*
names.
"""
if PY3:
import winreg as _winreg
else:
import _winreg
shell_folder_name = {
"CSIDL_APPDATA": "AppData",
"CSIDL_COMMON_APPDATA": "Common AppData",
"CSIDL_LOCAL_APPDATA": "Local AppData",
}[csidl_name]
key = _winreg.OpenKey(
_winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
)
dir, type = _winreg.QueryValueEx(key, shell_folder_name)
return dir
def _get_win_folder_with_pywin32(csidl_name):
from win32com.shell import shellcon, shell
dir = shell.SHGetFolderPath(0, getattr(shellcon, csidl_name), 0, 0)
# Try to make this a unicode path because SHGetFolderPath does
# not return unicode strings when there is unicode data in the
# path.
try:
dir = unicode(dir)
# Downgrade to short path name if have highbit chars. See
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
has_high_char = False
for c in dir:
if ord(c) > 255:
has_high_char = True
break
if has_high_char:
try:
import win32api
dir = win32api.GetShortPathName(dir)
except ImportError:
pass
except UnicodeError:
pass
return dir
def _get_win_folder_with_ctypes(csidl_name):
import ctypes
csidl_const = {
"CSIDL_APPDATA": 26,
"CSIDL_COMMON_APPDATA": 35,
"CSIDL_LOCAL_APPDATA": 28,
}[csidl_name]
buf = ctypes.create_unicode_buffer(1024)
ctypes.windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)
# Downgrade to short path name if have highbit chars. See
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
has_high_char = False
for c in buf:
if ord(c) > 255:
has_high_char = True
break
if has_high_char:
buf2 = ctypes.create_unicode_buffer(1024)
if ctypes.windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
buf = buf2
return buf.value
def _get_win_folder_with_jna(csidl_name):
import array
from com.sun import jna
from com.sun.jna.platform import win32
buf_size = win32.WinDef.MAX_PATH * 2
buf = array.zeros('c', buf_size)
shell = win32.Shell32.INSTANCE
shell.SHGetFolderPath(None, getattr(win32.ShlObj, csidl_name), None, win32.ShlObj.SHGFP_TYPE_CURRENT, buf)
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
# Downgrade to short path name if have highbit chars. See
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
has_high_char = False
for c in dir:
if ord(c) > 255:
has_high_char = True
break
if has_high_char:
buf = array.zeros('c', buf_size)
kernel = win32.Kernel32.INSTANCE
if kernel.GetShortPathName(dir, buf, buf_size):
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
return dir
if system == "win32":
try:
import win32com.shell
_get_win_folder = _get_win_folder_with_pywin32
except ImportError:
try:
from ctypes import windll
_get_win_folder = _get_win_folder_with_ctypes
except ImportError:
try:
import com.sun.jna
_get_win_folder = _get_win_folder_with_jna
except ImportError:
_get_win_folder = _get_win_folder_from_registry
#---- self test code
if __name__ == "__main__":
appname = "MyApp"
appauthor = "MyCompany"
props = ("user_data_dir",
"user_config_dir",
"user_cache_dir",
"user_state_dir",
"user_log_dir",
"site_data_dir",
"site_config_dir")
print("-- app dirs %s --" % __version__)
print("-- app dirs (with optional 'version')")
dirs = AppDirs(appname, appauthor, version="1.0")
for prop in props:
print("%s: %s" % (prop, getattr(dirs, prop)))
print("\n-- app dirs (without optional 'version')")
dirs = AppDirs(appname, appauthor)
for prop in props:
print("%s: %s" % (prop, getattr(dirs, prop)))
print("\n-- app dirs (without optional 'appauthor')")
dirs = AppDirs(appname)
for prop in props:
print("%s: %s" % (prop, getattr(dirs, prop)))
print("\n-- app dirs (with disabled 'appauthor')")
dirs = AppDirs(appname, appauthor=False)
for prop in props:
print("%s: %s" % (prop, getattr(dirs, prop)))

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ FORMAT_W3C: Final[str] = "YYYY-MM-DD HH:mm:ssZZ"
class DateTimeFormatter:
# 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
# emulated in Python's re library, see https://stackoverflow.com/a/13577411/2701578
@ -40,15 +41,18 @@ class DateTimeFormatter:
locale: locales.Locale
def __init__(self, locale: str = DEFAULT_LOCALE) -> None:
self.locale = locales.get_locale(locale)
def format(cls, dt: datetime, fmt: str) -> str:
# FIXME: _format_token() is nullable
return cls._FORMAT_RE.sub(
lambda m: cast(str, cls._format_token(dt, m.group(0))), fmt
)
def _format_token(self, dt: datetime, token: Optional[str]) -> Optional[str]:
if token and token.startswith("[") and token.endswith("]"):
return token[1:-1]

View file

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

View file

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

View file

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

View file

@ -1 +1,5 @@
__path__ = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore
# A Python "namespace package" http://www.python.org/dev/peps/pep-0382/
# This always goes inside of a namespace package's __init__.py
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__) # type: ignore

979
lib/backports/csv.py Normal file
View file

@ -0,0 +1,979 @@
# -*- coding: utf-8 -*-
"""A port of Python 3's csv module to Python 2.
The API of the csv module in Python 2 is drastically different from
the csv module in Python 3. This is due, for the most part, to the
difference between str in Python 2 and Python 3.
The semantics of Python 3's version are more useful because they support
unicode natively, while Python 2's csv does not.
"""
from __future__ import unicode_literals, absolute_import
__all__ = [ "QUOTE_MINIMAL", "QUOTE_ALL", "QUOTE_NONNUMERIC", "QUOTE_NONE",
"Error", "Dialect", "__doc__", "excel", "excel_tab",
"field_size_limit", "reader", "writer",
"register_dialect", "get_dialect", "list_dialects", "Sniffer",
"unregister_dialect", "__version__", "DictReader", "DictWriter" ]
import re
import numbers
from io import StringIO
from csv import (
QUOTE_MINIMAL, QUOTE_ALL, QUOTE_NONNUMERIC, QUOTE_NONE,
__version__, __doc__, Error, field_size_limit,
)
# Stuff needed from six
import sys
PY3 = sys.version_info[0] == 3
if PY3:
string_types = str
text_type = str
binary_type = bytes
unichr = chr
else:
string_types = basestring
text_type = unicode
binary_type = str
class QuoteStrategy(object):
quoting = None
def __init__(self, dialect):
if self.quoting is not None:
assert dialect.quoting == self.quoting
self.dialect = dialect
self.setup()
escape_pattern_quoted = r'({quotechar})'.format(
quotechar=re.escape(self.dialect.quotechar or '"'))
escape_pattern_unquoted = r'([{specialchars}])'.format(
specialchars=re.escape(self.specialchars))
self.escape_re_quoted = re.compile(escape_pattern_quoted)
self.escape_re_unquoted = re.compile(escape_pattern_unquoted)
def setup(self):
"""Optional method for strategy-wide optimizations."""
def quoted(self, field=None, raw_field=None, only=None):
"""Determine whether this field should be quoted."""
raise NotImplementedError(
'quoted must be implemented by a subclass')
@property
def specialchars(self):
"""The special characters that need to be escaped."""
raise NotImplementedError(
'specialchars must be implemented by a subclass')
def escape_re(self, quoted=None):
if quoted:
return self.escape_re_quoted
return self.escape_re_unquoted
def escapechar(self, quoted=None):
if quoted and self.dialect.doublequote:
return self.dialect.quotechar
return self.dialect.escapechar
def prepare(self, raw_field, only=None):
field = text_type(raw_field if raw_field is not None else '')
quoted = self.quoted(field=field, raw_field=raw_field, only=only)
escape_re = self.escape_re(quoted=quoted)
escapechar = self.escapechar(quoted=quoted)
if escape_re.search(field):
escapechar = '\\\\' if escapechar == '\\' else escapechar
if not escapechar:
raise Error('No escapechar is set')
escape_replace = r'{escapechar}\1'.format(escapechar=escapechar)
field = escape_re.sub(escape_replace, field)
if quoted:
field = '{quotechar}{field}{quotechar}'.format(
quotechar=self.dialect.quotechar, field=field)
return field
class QuoteMinimalStrategy(QuoteStrategy):
quoting = QUOTE_MINIMAL
def setup(self):
self.quoted_re = re.compile(r'[{specialchars}]'.format(
specialchars=re.escape(self.specialchars)))
@property
def specialchars(self):
return (
self.dialect.lineterminator +
self.dialect.quotechar +
self.dialect.delimiter +
(self.dialect.escapechar or '')
)
def quoted(self, field, only, **kwargs):
if field == self.dialect.quotechar and not self.dialect.doublequote:
# If the only character in the field is the quotechar, and
# doublequote is false, then just escape without outer quotes.
return False
return field == '' and only or bool(self.quoted_re.search(field))
class QuoteAllStrategy(QuoteStrategy):
quoting = QUOTE_ALL
@property
def specialchars(self):
return self.dialect.quotechar
def quoted(self, **kwargs):
return True
class QuoteNonnumericStrategy(QuoteStrategy):
quoting = QUOTE_NONNUMERIC
@property
def specialchars(self):
return (
self.dialect.lineterminator +
self.dialect.quotechar +
self.dialect.delimiter +
(self.dialect.escapechar or '')
)
def quoted(self, raw_field, **kwargs):
return not isinstance(raw_field, numbers.Number)
class QuoteNoneStrategy(QuoteStrategy):
quoting = QUOTE_NONE
@property
def specialchars(self):
return (
self.dialect.lineterminator +
(self.dialect.quotechar or '') +
self.dialect.delimiter +
(self.dialect.escapechar or '')
)
def quoted(self, field, only, **kwargs):
if field == '' and only:
raise Error('single empty field record must be quoted')
return False
class writer(object):
def __init__(self, fileobj, dialect='excel', **fmtparams):
if fileobj is None:
raise TypeError('fileobj must be file-like, not None')
self.fileobj = fileobj
if isinstance(dialect, text_type):
dialect = get_dialect(dialect)
try:
self.dialect = Dialect.combine(dialect, fmtparams)
except Error as e:
raise TypeError(*e.args)
strategies = {
QUOTE_MINIMAL: QuoteMinimalStrategy,
QUOTE_ALL: QuoteAllStrategy,
QUOTE_NONNUMERIC: QuoteNonnumericStrategy,
QUOTE_NONE: QuoteNoneStrategy,
}
self.strategy = strategies[self.dialect.quoting](self.dialect)
def writerow(self, row):
if row is None:
raise Error('row must be an iterable')
row = list(row)
only = len(row) == 1
row = [self.strategy.prepare(field, only=only) for field in row]
line = self.dialect.delimiter.join(row) + self.dialect.lineterminator
return self.fileobj.write(line)
def writerows(self, rows):
for row in rows:
self.writerow(row)
START_RECORD = 0
START_FIELD = 1
ESCAPED_CHAR = 2
IN_FIELD = 3
IN_QUOTED_FIELD = 4
ESCAPE_IN_QUOTED_FIELD = 5
QUOTE_IN_QUOTED_FIELD = 6
EAT_CRNL = 7
AFTER_ESCAPED_CRNL = 8
class reader(object):
def __init__(self, fileobj, dialect='excel', **fmtparams):
self.input_iter = iter(fileobj)
if isinstance(dialect, text_type):
dialect = get_dialect(dialect)
try:
self.dialect = Dialect.combine(dialect, fmtparams)
except Error as e:
raise TypeError(*e.args)
self.fields = None
self.field = None
self.line_num = 0
def parse_reset(self):
self.fields = []
self.field = []
self.state = START_RECORD
self.numeric_field = False
def parse_save_field(self):
field = ''.join(self.field)
self.field = []
if self.numeric_field:
field = float(field)
self.numeric_field = False
self.fields.append(field)
def parse_add_char(self, c):
if len(self.field) >= field_size_limit():
raise Error('field size limit exceeded')
self.field.append(c)
def parse_process_char(self, c):
switch = {
START_RECORD: self._parse_start_record,
START_FIELD: self._parse_start_field,
ESCAPED_CHAR: self._parse_escaped_char,
AFTER_ESCAPED_CRNL: self._parse_after_escaped_crnl,
IN_FIELD: self._parse_in_field,
IN_QUOTED_FIELD: self._parse_in_quoted_field,
ESCAPE_IN_QUOTED_FIELD: self._parse_escape_in_quoted_field,
QUOTE_IN_QUOTED_FIELD: self._parse_quote_in_quoted_field,
EAT_CRNL: self._parse_eat_crnl,
}
return switch[self.state](c)
def _parse_start_record(self, c):
if c == '\0':
return
elif c == '\n' or c == '\r':
self.state = EAT_CRNL
return
self.state = START_FIELD
return self._parse_start_field(c)
def _parse_start_field(self, c):
if c == '\n' or c == '\r' or c == '\0':
self.parse_save_field()
self.state = START_RECORD if c == '\0' else EAT_CRNL
elif (c == self.dialect.quotechar and
self.dialect.quoting != QUOTE_NONE):
self.state = IN_QUOTED_FIELD
elif c == self.dialect.escapechar:
self.state = ESCAPED_CHAR
elif c == ' ' and self.dialect.skipinitialspace:
pass # Ignore space at start of field
elif c == self.dialect.delimiter:
# Save empty field
self.parse_save_field()
else:
# Begin new unquoted field
if self.dialect.quoting == QUOTE_NONNUMERIC:
self.numeric_field = True
self.parse_add_char(c)
self.state = IN_FIELD
def _parse_escaped_char(self, c):
if c == '\n' or c == '\r':
self.parse_add_char(c)
self.state = AFTER_ESCAPED_CRNL
return
if c == '\0':
c = '\n'
self.parse_add_char(c)
self.state = IN_FIELD
def _parse_after_escaped_crnl(self, c):
if c == '\0':
return
return self._parse_in_field(c)
def _parse_in_field(self, c):
# In unquoted field
if c == '\n' or c == '\r' or c == '\0':
# End of line - return [fields]
self.parse_save_field()
self.state = START_RECORD if c == '\0' else EAT_CRNL
elif c == self.dialect.escapechar:
self.state = ESCAPED_CHAR
elif c == self.dialect.delimiter:
self.parse_save_field()
self.state = START_FIELD
else:
# Normal character - save in field
self.parse_add_char(c)
def _parse_in_quoted_field(self, c):
if c == '\0':
pass
elif c == self.dialect.escapechar:
self.state = ESCAPE_IN_QUOTED_FIELD
elif (c == self.dialect.quotechar and
self.dialect.quoting != QUOTE_NONE):
if self.dialect.doublequote:
self.state = QUOTE_IN_QUOTED_FIELD
else:
self.state = IN_FIELD
else:
self.parse_add_char(c)
def _parse_escape_in_quoted_field(self, c):
if c == '\0':
c = '\n'
self.parse_add_char(c)
self.state = IN_QUOTED_FIELD
def _parse_quote_in_quoted_field(self, c):
if (self.dialect.quoting != QUOTE_NONE and
c == self.dialect.quotechar):
# save "" as "
self.parse_add_char(c)
self.state = IN_QUOTED_FIELD
elif c == self.dialect.delimiter:
self.parse_save_field()
self.state = START_FIELD
elif c == '\n' or c == '\r' or c == '\0':
# End of line = return [fields]
self.parse_save_field()
self.state = START_RECORD if c == '\0' else EAT_CRNL
elif not self.dialect.strict:
self.parse_add_char(c)
self.state = IN_FIELD
else:
# illegal
raise Error("{delimiter}' expected after '{quotechar}".format(
delimiter=self.dialect.delimiter,
quotechar=self.dialect.quotechar,
))
def _parse_eat_crnl(self, c):
if c == '\n' or c == '\r':
pass
elif c == '\0':
self.state = START_RECORD
else:
raise Error('new-line character seen in unquoted field - do you '
'need to open the file in universal-newline mode?')
def __iter__(self):
return self
def __next__(self):
self.parse_reset()
while True:
try:
lineobj = next(self.input_iter)
except StopIteration:
if len(self.field) != 0 or self.state == IN_QUOTED_FIELD:
if self.dialect.strict:
raise Error('unexpected end of data')
self.parse_save_field()
if self.fields:
break
raise
if not isinstance(lineobj, text_type):
typ = type(lineobj)
typ_name = 'bytes' if typ == bytes else typ.__name__
err_str = ('iterator should return strings, not {0}'
' (did you open the file in text mode?)')
raise Error(err_str.format(typ_name))
self.line_num += 1
for c in lineobj:
if c == '\0':
raise Error('line contains NULL byte')
self.parse_process_char(c)
self.parse_process_char('\0')
if self.state == START_RECORD:
break
fields = self.fields
self.fields = None
return fields
next = __next__
_dialect_registry = {}
def register_dialect(name, dialect='excel', **fmtparams):
if not isinstance(name, text_type):
raise TypeError('"name" must be a string')
dialect = Dialect.extend(dialect, fmtparams)
try:
Dialect.validate(dialect)
except:
raise TypeError('dialect is invalid')
assert name not in _dialect_registry
_dialect_registry[name] = dialect
def unregister_dialect(name):
try:
_dialect_registry.pop(name)
except KeyError:
raise Error('"{name}" not a registered dialect'.format(name=name))
def get_dialect(name):
try:
return _dialect_registry[name]
except KeyError:
raise Error('Could not find dialect {0}'.format(name))
def list_dialects():
return list(_dialect_registry)
class Dialect(object):
"""Describe a CSV dialect.
This must be subclassed (see csv.excel). Valid attributes are:
delimiter, quotechar, escapechar, doublequote, skipinitialspace,
lineterminator, quoting, strict.
"""
_name = ""
_valid = False
# placeholders
delimiter = None
quotechar = None
escapechar = None
doublequote = None
skipinitialspace = None
lineterminator = None
quoting = None
strict = None
def __init__(self):
self.validate(self)
if self.__class__ != Dialect:
self._valid = True
@classmethod
def validate(cls, dialect):
dialect = cls.extend(dialect)
if not isinstance(dialect.quoting, int):
raise Error('"quoting" must be an integer')
if dialect.delimiter is None:
raise Error('delimiter must be set')
cls.validate_text(dialect, 'delimiter')
if dialect.lineterminator is None:
raise Error('lineterminator must be set')
if not isinstance(dialect.lineterminator, text_type):
raise Error('"lineterminator" must be a string')
if dialect.quoting not in [
QUOTE_NONE, QUOTE_MINIMAL, QUOTE_NONNUMERIC, QUOTE_ALL]:
raise Error('Invalid quoting specified')
if dialect.quoting != QUOTE_NONE:
if dialect.quotechar is None and dialect.escapechar is None:
raise Error('quotechar must be set if quoting enabled')
if dialect.quotechar is not None:
cls.validate_text(dialect, 'quotechar')
@staticmethod
def validate_text(dialect, attr):
val = getattr(dialect, attr)
if not isinstance(val, text_type):
if type(val) == bytes:
raise Error('"{0}" must be string, not bytes'.format(attr))
raise Error('"{0}" must be string, not {1}'.format(
attr, type(val).__name__))
if len(val) != 1:
raise Error('"{0}" must be a 1-character string'.format(attr))
@staticmethod
def defaults():
return {
'delimiter': ',',
'doublequote': True,
'escapechar': None,
'lineterminator': '\r\n',
'quotechar': '"',
'quoting': QUOTE_MINIMAL,
'skipinitialspace': False,
'strict': False,
}
@classmethod
def extend(cls, dialect, fmtparams=None):
if isinstance(dialect, string_types):
dialect = get_dialect(dialect)
if fmtparams is None:
return dialect
defaults = cls.defaults()
if any(param not in defaults for param in fmtparams):
raise TypeError('Invalid fmtparam')
specified = dict(
(attr, getattr(dialect, attr, None))
for attr in cls.defaults()
)
specified.update(fmtparams)
return type(str('ExtendedDialect'), (cls,), specified)
@classmethod
def combine(cls, dialect, fmtparams):
"""Create a new dialect with defaults and added parameters."""
dialect = cls.extend(dialect, fmtparams)
defaults = cls.defaults()
specified = dict(
(attr, getattr(dialect, attr, None))
for attr in defaults
if getattr(dialect, attr, None) is not None or
attr in ['quotechar', 'delimiter', 'lineterminator', 'quoting']
)
defaults.update(specified)
dialect = type(str('CombinedDialect'), (cls,), defaults)
cls.validate(dialect)
return dialect()
def __delattr__(self, attr):
if self._valid:
raise AttributeError('dialect is immutable.')
super(Dialect, self).__delattr__(attr)
def __setattr__(self, attr, value):
if self._valid:
raise AttributeError('dialect is immutable.')
super(Dialect, self).__setattr__(attr, value)
class excel(Dialect):
"""Describe the usual properties of Excel-generated CSV files."""
delimiter = ','
quotechar = '"'
doublequote = True
skipinitialspace = False
lineterminator = '\r\n'
quoting = QUOTE_MINIMAL
register_dialect("excel", excel)
class excel_tab(excel):
"""Describe the usual properties of Excel-generated TAB-delimited files."""
delimiter = '\t'
register_dialect("excel-tab", excel_tab)
class unix_dialect(Dialect):
"""Describe the usual properties of Unix-generated CSV files."""
delimiter = ','
quotechar = '"'
doublequote = True
skipinitialspace = False
lineterminator = '\n'
quoting = QUOTE_ALL
register_dialect("unix", unix_dialect)
class DictReader(object):
def __init__(self, f, fieldnames=None, restkey=None, restval=None,
dialect="excel", *args, **kwds):
self._fieldnames = fieldnames # list of keys for the dict
self.restkey = restkey # key to catch long rows
self.restval = restval # default value for short rows
self.reader = reader(f, dialect, *args, **kwds)
self.dialect = dialect
self.line_num = 0
def __iter__(self):
return self
@property
def fieldnames(self):
if self._fieldnames is None:
try:
self._fieldnames = next(self.reader)
except StopIteration:
pass
self.line_num = self.reader.line_num
return self._fieldnames
@fieldnames.setter
def fieldnames(self, value):
self._fieldnames = value
def __next__(self):
if self.line_num == 0:
# Used only for its side effect.
self.fieldnames
row = next(self.reader)
self.line_num = self.reader.line_num
# unlike the basic reader, we prefer not to return blanks,
# because we will typically wind up with a dict full of None
# values
while row == []:
row = next(self.reader)
d = dict(zip(self.fieldnames, row))
lf = len(self.fieldnames)
lr = len(row)
if lf < lr:
d[self.restkey] = row[lf:]
elif lf > lr:
for key in self.fieldnames[lr:]:
d[key] = self.restval
return d
next = __next__
class DictWriter(object):
def __init__(self, f, fieldnames, restval="", extrasaction="raise",
dialect="excel", *args, **kwds):
self.fieldnames = fieldnames # list of keys for the dict
self.restval = restval # for writing short dicts
if extrasaction.lower() not in ("raise", "ignore"):
raise ValueError("extrasaction (%s) must be 'raise' or 'ignore'"
% extrasaction)
self.extrasaction = extrasaction
self.writer = writer(f, dialect, *args, **kwds)
def writeheader(self):
header = dict(zip(self.fieldnames, self.fieldnames))
self.writerow(header)
def _dict_to_list(self, rowdict):
if self.extrasaction == "raise":
wrong_fields = [k for k in rowdict if k not in self.fieldnames]
if wrong_fields:
raise ValueError("dict contains fields not in fieldnames: "
+ ", ".join([repr(x) for x in wrong_fields]))
return (rowdict.get(key, self.restval) for key in self.fieldnames)
def writerow(self, rowdict):
return self.writer.writerow(self._dict_to_list(rowdict))
def writerows(self, rowdicts):
return self.writer.writerows(map(self._dict_to_list, rowdicts))
# Guard Sniffer's type checking against builds that exclude complex()
try:
complex
except NameError:
complex = float
class Sniffer(object):
'''
"Sniffs" the format of a CSV file (i.e. delimiter, quotechar)
Returns a Dialect object.
'''
def __init__(self):
# in case there is more than one possible delimiter
self.preferred = [',', '\t', ';', ' ', ':']
def sniff(self, sample, delimiters=None):
"""
Returns a dialect (or None) corresponding to the sample
"""
quotechar, doublequote, delimiter, skipinitialspace = \
self._guess_quote_and_delimiter(sample, delimiters)
if not delimiter:
delimiter, skipinitialspace = self._guess_delimiter(sample,
delimiters)
if not delimiter:
raise Error("Could not determine delimiter")
class dialect(Dialect):
_name = "sniffed"
lineterminator = '\r\n'
quoting = QUOTE_MINIMAL
# escapechar = ''
dialect.doublequote = doublequote
dialect.delimiter = delimiter
# _csv.reader won't accept a quotechar of ''
dialect.quotechar = quotechar or '"'
dialect.skipinitialspace = skipinitialspace
return dialect
def _guess_quote_and_delimiter(self, data, delimiters):
"""
Looks for text enclosed between two identical quotes
(the probable quotechar) which are preceded and followed
by the same character (the probable delimiter).
For example:
,'some text',
The quote with the most wins, same with the delimiter.
If there is no quotechar the delimiter can't be determined
this way.
"""
matches = []
for restr in ('(?P<delim>[^\w\n"\'])(?P<space> ?)(?P<quote>["\']).*?(?P=quote)(?P=delim)', # ,".*?",
'(?:^|\n)(?P<quote>["\']).*?(?P=quote)(?P<delim>[^\w\n"\'])(?P<space> ?)', # ".*?",
'(?P<delim>>[^\w\n"\'])(?P<space> ?)(?P<quote>["\']).*?(?P=quote)(?:$|\n)', # ,".*?"
'(?:^|\n)(?P<quote>["\']).*?(?P=quote)(?:$|\n)'): # ".*?" (no delim, no space)
regexp = re.compile(restr, re.DOTALL | re.MULTILINE)
matches = regexp.findall(data)
if matches:
break
if not matches:
# (quotechar, doublequote, delimiter, skipinitialspace)
return ('', False, None, 0)
quotes = {}
delims = {}
spaces = 0
groupindex = regexp.groupindex
for m in matches:
n = groupindex['quote'] - 1
key = m[n]
if key:
quotes[key] = quotes.get(key, 0) + 1
try:
n = groupindex['delim'] - 1
key = m[n]
except KeyError:
continue
if key and (delimiters is None or key in delimiters):
delims[key] = delims.get(key, 0) + 1
try:
n = groupindex['space'] - 1
except KeyError:
continue
if m[n]:
spaces += 1
quotechar = max(quotes, key=quotes.get)
if delims:
delim = max(delims, key=delims.get)
skipinitialspace = delims[delim] == spaces
if delim == '\n': # most likely a file with a single column
delim = ''
else:
# there is *no* delimiter, it's a single column of quoted data
delim = ''
skipinitialspace = 0
# if we see an extra quote between delimiters, we've got a
# double quoted format
dq_regexp = re.compile(
r"((%(delim)s)|^)\W*%(quote)s[^%(delim)s\n]*%(quote)s[^%(delim)s\n]*%(quote)s\W*((%(delim)s)|$)" % \
{'delim':re.escape(delim), 'quote':quotechar}, re.MULTILINE)
if dq_regexp.search(data):
doublequote = True
else:
doublequote = False
return (quotechar, doublequote, delim, skipinitialspace)
def _guess_delimiter(self, data, delimiters):
"""
The delimiter /should/ occur the same number of times on
each row. However, due to malformed data, it may not. We don't want
an all or nothing approach, so we allow for small variations in this
number.
1) build a table of the frequency of each character on every line.
2) build a table of frequencies of this frequency (meta-frequency?),
e.g. 'x occurred 5 times in 10 rows, 6 times in 1000 rows,
7 times in 2 rows'
3) use the mode of the meta-frequency to determine the /expected/
frequency for that character
4) find out how often the character actually meets that goal
5) the character that best meets its goal is the delimiter
For performance reasons, the data is evaluated in chunks, so it can
try and evaluate the smallest portion of the data possible, evaluating
additional chunks as necessary.
"""
data = list(filter(None, data.split('\n')))
ascii = [unichr(c) for c in range(127)] # 7-bit ASCII
# build frequency tables
chunkLength = min(10, len(data))
iteration = 0
charFrequency = {}
modes = {}
delims = {}
start, end = 0, min(chunkLength, len(data))
while start < len(data):
iteration += 1
for line in data[start:end]:
for char in ascii:
metaFrequency = charFrequency.get(char, {})
# must count even if frequency is 0
freq = line.count(char)
# value is the mode
metaFrequency[freq] = metaFrequency.get(freq, 0) + 1
charFrequency[char] = metaFrequency
for char in charFrequency.keys():
items = list(charFrequency[char].items())
if len(items) == 1 and items[0][0] == 0:
continue
# get the mode of the frequencies
if len(items) > 1:
modes[char] = max(items, key=lambda x: x[1])
# adjust the mode - subtract the sum of all
# other frequencies
items.remove(modes[char])
modes[char] = (modes[char][0], modes[char][1]
- sum(item[1] for item in items))
else:
modes[char] = items[0]
# build a list of possible delimiters
modeList = modes.items()
total = float(chunkLength * iteration)
# (rows of consistent data) / (number of rows) = 100%
consistency = 1.0
# minimum consistency threshold
threshold = 0.9
while len(delims) == 0 and consistency >= threshold:
for k, v in modeList:
if v[0] > 0 and v[1] > 0:
if ((v[1]/total) >= consistency and
(delimiters is None or k in delimiters)):
delims[k] = v
consistency -= 0.01
if len(delims) == 1:
delim = list(delims.keys())[0]
skipinitialspace = (data[0].count(delim) ==
data[0].count("%c " % delim))
return (delim, skipinitialspace)
# analyze another chunkLength lines
start = end
end += chunkLength
if not delims:
return ('', 0)
# if there's more than one, fall back to a 'preferred' list
if len(delims) > 1:
for d in self.preferred:
if d in delims.keys():
skipinitialspace = (data[0].count(d) ==
data[0].count("%c " % d))
return (d, skipinitialspace)
# nothing else indicates a preference, pick the character that
# dominates(?)
items = [(v,k) for (k,v) in delims.items()]
items.sort()
delim = items[-1][1]
skipinitialspace = (data[0].count(delim) ==
data[0].count("%c " % delim))
return (delim, skipinitialspace)
def has_header(self, sample):
# Creates a dictionary of types of data in each column. If any
# column is of a single type (say, integers), *except* for the first
# row, then the first row is presumed to be labels. If the type
# can't be determined, it is assumed to be a string in which case
# the length of the string is the determining factor: if all of the
# rows except for the first are the same length, it's a header.
# Finally, a 'vote' is taken at the end for each column, adding or
# subtracting from the likelihood of the first row being a header.
rdr = reader(StringIO(sample), self.sniff(sample))
header = next(rdr) # assume first row is header
columns = len(header)
columnTypes = {}
for i in range(columns): columnTypes[i] = None
checked = 0
for row in rdr:
# arbitrary number of rows to check, to keep it sane
if checked > 20:
break
checked += 1
if len(row) != columns:
continue # skip rows that have irregular number of columns
for col in list(columnTypes.keys()):
for thisType in [int, float, complex]:
try:
thisType(row[col])
break
except (ValueError, OverflowError):
pass
else:
# fallback to length of string
thisType = len(row[col])
if thisType != columnTypes[col]:
if columnTypes[col] is None: # add new column type
columnTypes[col] = thisType
else:
# type is inconsistent, remove column from
# consideration
del columnTypes[col]
# finally, compare results against first row and "vote"
# on whether it's a header
hasHeader = 0
for col, colType in columnTypes.items():
if type(colType) == type(0): # it's a length
if len(header[col]) != colType:
hasHeader += 1
else:
hasHeader -= 1
else: # attempt typecast
try:
colType(header[col])
except (ValueError, TypeError):
hasHeader += 1
else:
hasHeader -= 1
return hasHeader > 0

View file

@ -0,0 +1,196 @@
from __future__ import absolute_import
import functools
from collections import namedtuple
from threading import RLock
_CacheInfo = namedtuple("_CacheInfo", ["hits", "misses", "maxsize", "currsize"])
@functools.wraps(functools.update_wrapper)
def update_wrapper(
wrapper,
wrapped,
assigned=functools.WRAPPER_ASSIGNMENTS,
updated=functools.WRAPPER_UPDATES,
):
"""
Patch two bugs in functools.update_wrapper.
"""
# workaround for http://bugs.python.org/issue3445
assigned = tuple(attr for attr in assigned if hasattr(wrapped, attr))
wrapper = functools.update_wrapper(wrapper, wrapped, assigned, updated)
# workaround for https://bugs.python.org/issue17482
wrapper.__wrapped__ = wrapped
return wrapper
class _HashedSeq(list):
__slots__ = 'hashvalue'
def __init__(self, tup, hash=hash):
self[:] = tup
self.hashvalue = hash(tup)
def __hash__(self):
return self.hashvalue
def _make_key(
args,
kwds,
typed,
kwd_mark=(object(),),
fasttypes=set([int, str, frozenset, type(None)]),
sorted=sorted,
tuple=tuple,
type=type,
len=len,
):
'Make a cache key from optionally typed positional and keyword arguments'
key = args
if kwds:
sorted_items = sorted(kwds.items())
key += kwd_mark
for item in sorted_items:
key += item
if typed:
key += tuple(type(v) for v in args)
if kwds:
key += tuple(type(v) for k, v in sorted_items)
elif len(key) == 1 and type(key[0]) in fasttypes:
return key[0]
return _HashedSeq(key)
def lru_cache(maxsize=100, typed=False): # noqa: C901
"""Least-recently-used cache decorator.
If *maxsize* is set to None, the LRU features are disabled and the cache
can grow without bound.
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
distinct results.
Arguments to the cached function must be hashable.
View the cache statistics named tuple (hits, misses, maxsize, currsize) with
f.cache_info(). Clear the cache and statistics with f.cache_clear().
Access the underlying function with f.__wrapped__.
See: http://en.wikipedia.org/wiki/Cache_algorithms#Least_Recently_Used
"""
# Users should only access the lru_cache through its public API:
# cache_info, cache_clear, and f.__wrapped__
# The internals of the lru_cache are encapsulated for thread safety and
# to allow the implementation to change (including a possible C version).
def decorating_function(user_function):
cache = dict()
stats = [0, 0] # make statistics updateable non-locally
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 decorating_function

File diff suppressed because it is too large Load diff

View file

@ -1,5 +0,0 @@
from . import main
if __name__ == '__main__':
main()

View file

@ -1,24 +0,0 @@
import sys
if sys.version_info < (3, 9):
def removesuffix(self, suffix):
# suffix='' should not call self[:-0].
if suffix and self.endswith(suffix):
return self[: -len(suffix)]
else:
return self[:]
def removeprefix(self, prefix):
if self.startswith(prefix):
return self[len(prefix) :]
else:
return self[:]
else:
def removesuffix(self, suffix):
return self.removesuffix(suffix)
def removeprefix(self, prefix):
return self.removeprefix(prefix)

View file

@ -0,0 +1,49 @@
__all__ = [
"ZoneInfo",
"reset_tzpath",
"available_timezones",
"TZPATH",
"ZoneInfoNotFoundError",
"InvalidTZPathWarning",
]
import sys
from . import _tzpath
from ._common import ZoneInfoNotFoundError
from ._version import __version__
try:
from ._czoneinfo import ZoneInfo
except ImportError: # pragma: nocover
from ._zoneinfo import ZoneInfo
reset_tzpath = _tzpath.reset_tzpath
available_timezones = _tzpath.available_timezones
InvalidTZPathWarning = _tzpath.InvalidTZPathWarning
if sys.version_info < (3, 7):
# Module-level __getattr__ was added in Python 3.7, so instead of lazily
# populating TZPATH on every access, we will register a callback with
# reset_tzpath to update the top-level tuple.
TZPATH = _tzpath.TZPATH
def _tzpath_callback(new_tzpath):
global TZPATH
TZPATH = new_tzpath
_tzpath.TZPATH_CALLBACKS.append(_tzpath_callback)
del _tzpath_callback
else:
def __getattr__(name):
if name == "TZPATH":
return _tzpath.TZPATH
else:
raise AttributeError(
f"module {__name__!r} has no attribute {name!r}"
)
def __dir__():
return sorted(list(globals()) + ["TZPATH"])

View file

@ -0,0 +1,45 @@
import os
import typing
from datetime import datetime, tzinfo
from typing import (
Any,
Iterable,
Optional,
Protocol,
Sequence,
Set,
Type,
Union,
)
_T = typing.TypeVar("_T", bound="ZoneInfo")
class _IOBytes(Protocol):
def read(self, __size: int) -> bytes: ...
def seek(self, __size: int, __whence: int = ...) -> Any: ...
class ZoneInfo(tzinfo):
@property
def key(self) -> str: ...
def __init__(self, key: str) -> None: ...
@classmethod
def no_cache(cls: Type[_T], key: str) -> _T: ...
@classmethod
def from_file(
cls: Type[_T], __fobj: _IOBytes, key: Optional[str] = ...
) -> _T: ...
@classmethod
def clear_cache(cls, *, only_keys: Iterable[str] = ...) -> None: ...
# Note: Both here and in clear_cache, the types allow the use of `str` where
# a sequence of strings is required. This should be remedied if a solution
# to this typing bug is found: https://github.com/python/typing/issues/256
def reset_tzpath(
to: Optional[Sequence[Union[os.PathLike, str]]] = ...
) -> None: ...
def available_timezones() -> Set[str]: ...
TZPATH: Sequence[str]
class ZoneInfoNotFoundError(KeyError): ...
class InvalidTZPathWarning(RuntimeWarning): ...

View file

@ -0,0 +1,171 @@
import struct
def load_tzdata(key):
try:
import importlib.resources as importlib_resources
except ImportError:
import importlib_resources
components = key.split("/")
package_name = ".".join(["tzdata.zoneinfo"] + components[:-1])
resource_name = components[-1]
try:
return importlib_resources.open_binary(package_name, resource_name)
except (ImportError, FileNotFoundError, UnicodeEncodeError):
# There are three types of exception that can be raised that all amount
# to "we cannot find this key":
#
# ImportError: If package_name doesn't exist (e.g. if tzdata is not
# installed, or if there's an error in the folder name like
# Amrica/New_York)
# FileNotFoundError: If resource_name doesn't exist in the package
# (e.g. Europe/Krasnoy)
# UnicodeEncodeError: If package_name or resource_name are not UTF-8,
# such as keys containing a surrogate character.
raise ZoneInfoNotFoundError(f"No time zone found with key {key}")
def load_data(fobj):
header = _TZifHeader.from_file(fobj)
if header.version == 1:
time_size = 4
time_type = "l"
else:
# Version 2+ has 64-bit integer transition times
time_size = 8
time_type = "q"
# Version 2+ also starts with a Version 1 header and data, which
# we need to skip now
skip_bytes = (
header.timecnt * 5 # Transition times and types
+ header.typecnt * 6 # Local time type records
+ header.charcnt # Time zone designations
+ header.leapcnt * 8 # Leap second records
+ header.isstdcnt # Standard/wall indicators
+ header.isutcnt # UT/local indicators
)
fobj.seek(skip_bytes, 1)
# Now we need to read the second header, which is not the same
# as the first
header = _TZifHeader.from_file(fobj)
typecnt = header.typecnt
timecnt = header.timecnt
charcnt = header.charcnt
# The data portion starts with timecnt transitions and indices
if timecnt:
trans_list_utc = struct.unpack(
f">{timecnt}{time_type}", fobj.read(timecnt * time_size)
)
trans_idx = struct.unpack(f">{timecnt}B", fobj.read(timecnt))
else:
trans_list_utc = ()
trans_idx = ()
# Read the ttinfo struct, (utoff, isdst, abbrind)
if typecnt:
utcoff, isdst, abbrind = zip(
*(struct.unpack(">lbb", fobj.read(6)) for i in range(typecnt))
)
else:
utcoff = ()
isdst = ()
abbrind = ()
# Now read the abbreviations. They are null-terminated strings, indexed
# not by position in the array but by position in the unsplit
# abbreviation string. I suppose this makes more sense in C, which uses
# null to terminate the strings, but it's inconvenient here...
abbr_vals = {}
abbr_chars = fobj.read(charcnt)
def get_abbr(idx):
# Gets a string starting at idx and running until the next \x00
#
# We cannot pre-populate abbr_vals by splitting on \x00 because there
# are some zones that use subsets of longer abbreviations, like so:
#
# LMT\x00AHST\x00HDT\x00
#
# Where the idx to abbr mapping should be:
#
# {0: "LMT", 4: "AHST", 5: "HST", 9: "HDT"}
if idx not in abbr_vals:
span_end = abbr_chars.find(b"\x00", idx)
abbr_vals[idx] = abbr_chars[idx:span_end].decode()
return abbr_vals[idx]
abbr = tuple(get_abbr(idx) for idx in abbrind)
# The remainder of the file consists of leap seconds (currently unused) and
# the standard/wall and ut/local indicators, which are metadata we don't need.
# In version 2 files, we need to skip the unnecessary data to get at the TZ string:
if header.version >= 2:
# Each leap second record has size (time_size + 4)
skip_bytes = header.isutcnt + header.isstdcnt + header.leapcnt * 12
fobj.seek(skip_bytes, 1)
c = fobj.read(1) # Should be \n
assert c == b"\n", c
tz_bytes = b""
while True:
c = fobj.read(1)
if c == b"\n":
break
tz_bytes += c
tz_str = tz_bytes
else:
tz_str = None
return trans_idx, trans_list_utc, utcoff, isdst, abbr, tz_str
class _TZifHeader:
__slots__ = [
"version",
"isutcnt",
"isstdcnt",
"leapcnt",
"timecnt",
"typecnt",
"charcnt",
]
def __init__(self, *args):
assert len(self.__slots__) == len(args)
for attr, val in zip(self.__slots__, args):
setattr(self, attr, val)
@classmethod
def from_file(cls, stream):
# The header starts with a 4-byte "magic" value
if stream.read(4) != b"TZif":
raise ValueError("Invalid TZif file: magic not found")
_version = stream.read(1)
if _version == b"\x00":
version = 1
else:
version = int(_version)
stream.read(15)
args = (version,)
# Slots are defined in the order that the bytes are arranged
args = args + struct.unpack(">6l", stream.read(24))
return cls(*args)
class ZoneInfoNotFoundError(KeyError):
"""Exception raised when a ZoneInfo key is not found."""

View file

@ -0,0 +1,207 @@
import os
import sys
PY36 = sys.version_info < (3, 7)
def reset_tzpath(to=None):
global TZPATH
tzpaths = to
if tzpaths is not None:
if isinstance(tzpaths, (str, bytes)):
raise TypeError(
f"tzpaths must be a list or tuple, "
+ f"not {type(tzpaths)}: {tzpaths!r}"
)
if not all(map(os.path.isabs, tzpaths)):
raise ValueError(_get_invalid_paths_message(tzpaths))
base_tzpath = tzpaths
else:
env_var = os.environ.get("PYTHONTZPATH", None)
if env_var is not None:
base_tzpath = _parse_python_tzpath(env_var)
elif sys.platform != "win32":
base_tzpath = [
"/usr/share/zoneinfo",
"/usr/lib/zoneinfo",
"/usr/share/lib/zoneinfo",
"/etc/zoneinfo",
]
base_tzpath.sort(key=lambda x: not os.path.exists(x))
else:
base_tzpath = ()
TZPATH = tuple(base_tzpath)
if TZPATH_CALLBACKS:
for callback in TZPATH_CALLBACKS:
callback(TZPATH)
def _parse_python_tzpath(env_var):
if not env_var:
return ()
raw_tzpath = env_var.split(os.pathsep)
new_tzpath = tuple(filter(os.path.isabs, raw_tzpath))
# If anything has been filtered out, we will warn about it
if len(new_tzpath) != len(raw_tzpath):
import warnings
msg = _get_invalid_paths_message(raw_tzpath)
warnings.warn(
"Invalid paths specified in PYTHONTZPATH environment variable."
+ msg,
InvalidTZPathWarning,
)
return new_tzpath
def _get_invalid_paths_message(tzpaths):
invalid_paths = (path for path in tzpaths if not os.path.isabs(path))
prefix = "\n "
indented_str = prefix + prefix.join(invalid_paths)
return (
"Paths should be absolute but found the following relative paths:"
+ indented_str
)
if sys.version_info < (3, 8):
def _isfile(path):
# bpo-33721: In Python 3.8 non-UTF8 paths return False rather than
# raising an error. See https://bugs.python.org/issue33721
try:
return os.path.isfile(path)
except ValueError:
return False
else:
_isfile = os.path.isfile
def find_tzfile(key):
"""Retrieve the path to a TZif file from a key."""
_validate_tzfile_path(key)
for search_path in TZPATH:
filepath = os.path.join(search_path, key)
if _isfile(filepath):
return filepath
return None
_TEST_PATH = os.path.normpath(os.path.join("_", "_"))[:-1]
def _validate_tzfile_path(path, _base=_TEST_PATH):
if os.path.isabs(path):
raise ValueError(
f"ZoneInfo keys may not be absolute paths, got: {path}"
)
# We only care about the kinds of path normalizations that would change the
# length of the key - e.g. a/../b -> a/b, or a/b/ -> a/b. On Windows,
# normpath will also change from a/b to a\b, but that would still preserve
# the length.
new_path = os.path.normpath(path)
if len(new_path) != len(path):
raise ValueError(
f"ZoneInfo keys must be normalized relative paths, got: {path}"
)
resolved = os.path.normpath(os.path.join(_base, new_path))
if not resolved.startswith(_base):
raise ValueError(
f"ZoneInfo keys must refer to subdirectories of TZPATH, got: {path}"
)
del _TEST_PATH
def available_timezones():
"""Returns a set containing all available time zones.
.. caution::
This may attempt to open a large number of files, since the best way to
determine if a given file on the time zone search path is to open it
and check for the "magic string" at the beginning.
"""
try:
from importlib import resources
except ImportError:
import importlib_resources as resources
valid_zones = set()
# Start with loading from the tzdata package if it exists: this has a
# pre-assembled list of zones that only requires opening one file.
try:
with resources.open_text("tzdata", "zones") as f:
for zone in f:
zone = zone.strip()
if zone:
valid_zones.add(zone)
except (ImportError, FileNotFoundError):
pass
def valid_key(fpath):
try:
with open(fpath, "rb") as f:
return f.read(4) == b"TZif"
except Exception: # pragma: nocover
return False
for tz_root in TZPATH:
if not os.path.exists(tz_root):
continue
for root, dirnames, files in os.walk(tz_root):
if root == tz_root:
# right/ and posix/ are special directories and shouldn't be
# included in the output of available zones
if "right" in dirnames:
dirnames.remove("right")
if "posix" in dirnames:
dirnames.remove("posix")
for file in files:
fpath = os.path.join(root, file)
key = os.path.relpath(fpath, start=tz_root)
if os.sep != "/": # pragma: nocover
key = key.replace(os.sep, "/")
if not key or key in valid_zones:
continue
if valid_key(fpath):
valid_zones.add(key)
if "posixrules" in valid_zones:
# posixrules is a special symlink-only time zone where it exists, it
# should not be included in the output
valid_zones.remove("posixrules")
return valid_zones
class InvalidTZPathWarning(RuntimeWarning):
"""Warning raised if an invalid path is specified in PYTHONTZPATH."""
TZPATH = ()
TZPATH_CALLBACKS = []
reset_tzpath()

View file

@ -0,0 +1 @@
__version__ = "0.2.1"

View file

@ -0,0 +1,754 @@
import bisect
import calendar
import collections
import functools
import re
import weakref
from datetime import datetime, timedelta, tzinfo
from . import _common, _tzpath
EPOCH = datetime(1970, 1, 1)
EPOCHORDINAL = datetime(1970, 1, 1).toordinal()
# It is relatively expensive to construct new timedelta objects, and in most
# cases we're looking at the same deltas, like integer numbers of hours, etc.
# To improve speed and memory use, we'll keep a dictionary with references
# to the ones we've already used so far.
#
# Loading every time zone in the 2020a version of the time zone database
# requires 447 timedeltas, which requires approximately the amount of space
# that ZoneInfo("America/New_York") with 236 transitions takes up, so we will
# set the cache size to 512 so that in the common case we always get cache
# hits, but specifically crafted ZoneInfo objects don't leak arbitrary amounts
# of memory.
@functools.lru_cache(maxsize=512)
def _load_timedelta(seconds):
return timedelta(seconds=seconds)
class ZoneInfo(tzinfo):
_strong_cache_size = 8
_strong_cache = collections.OrderedDict()
_weak_cache = weakref.WeakValueDictionary()
__module__ = "backports.zoneinfo"
def __init_subclass__(cls):
cls._strong_cache = collections.OrderedDict()
cls._weak_cache = weakref.WeakValueDictionary()
def __new__(cls, key):
instance = cls._weak_cache.get(key, None)
if instance is None:
instance = cls._weak_cache.setdefault(key, cls._new_instance(key))
instance._from_cache = True
# Update the "strong" cache
cls._strong_cache[key] = cls._strong_cache.pop(key, instance)
if len(cls._strong_cache) > cls._strong_cache_size:
cls._strong_cache.popitem(last=False)
return instance
@classmethod
def no_cache(cls, key):
obj = cls._new_instance(key)
obj._from_cache = False
return obj
@classmethod
def _new_instance(cls, key):
obj = super().__new__(cls)
obj._key = key
obj._file_path = obj._find_tzfile(key)
if obj._file_path is not None:
file_obj = open(obj._file_path, "rb")
else:
file_obj = _common.load_tzdata(key)
with file_obj as f:
obj._load_file(f)
return obj
@classmethod
def from_file(cls, fobj, key=None):
obj = super().__new__(cls)
obj._key = key
obj._file_path = None
obj._load_file(fobj)
obj._file_repr = repr(fobj)
# Disable pickling for objects created from files
obj.__reduce__ = obj._file_reduce
return obj
@classmethod
def clear_cache(cls, *, only_keys=None):
if only_keys is not None:
for key in only_keys:
cls._weak_cache.pop(key, None)
cls._strong_cache.pop(key, None)
else:
cls._weak_cache.clear()
cls._strong_cache.clear()
@property
def key(self):
return self._key
def utcoffset(self, dt):
return self._find_trans(dt).utcoff
def dst(self, dt):
return self._find_trans(dt).dstoff
def tzname(self, dt):
return self._find_trans(dt).tzname
def fromutc(self, dt):
"""Convert from datetime in UTC to datetime in local time"""
if not isinstance(dt, datetime):
raise TypeError("fromutc() requires a datetime argument")
if dt.tzinfo is not self:
raise ValueError("dt.tzinfo is not self")
timestamp = self._get_local_timestamp(dt)
num_trans = len(self._trans_utc)
if num_trans >= 1 and timestamp < self._trans_utc[0]:
tti = self._tti_before
fold = 0
elif (
num_trans == 0 or timestamp > self._trans_utc[-1]
) and not isinstance(self._tz_after, _ttinfo):
tti, fold = self._tz_after.get_trans_info_fromutc(
timestamp, dt.year
)
elif num_trans == 0:
tti = self._tz_after
fold = 0
else:
idx = bisect.bisect_right(self._trans_utc, timestamp)
if num_trans > 1 and timestamp >= self._trans_utc[1]:
tti_prev, tti = self._ttinfos[idx - 2 : idx]
elif timestamp > self._trans_utc[-1]:
tti_prev = self._ttinfos[-1]
tti = self._tz_after
else:
tti_prev = self._tti_before
tti = self._ttinfos[0]
# Detect fold
shift = tti_prev.utcoff - tti.utcoff
fold = shift.total_seconds() > timestamp - self._trans_utc[idx - 1]
dt += tti.utcoff
if fold:
return dt.replace(fold=1)
else:
return dt
def _find_trans(self, dt):
if dt is None:
if self._fixed_offset:
return self._tz_after
else:
return _NO_TTINFO
ts = self._get_local_timestamp(dt)
lt = self._trans_local[dt.fold]
num_trans = len(lt)
if num_trans and ts < lt[0]:
return self._tti_before
elif not num_trans or ts > lt[-1]:
if isinstance(self._tz_after, _TZStr):
return self._tz_after.get_trans_info(ts, dt.year, dt.fold)
else:
return self._tz_after
else:
# idx is the transition that occurs after this timestamp, so we
# subtract off 1 to get the current ttinfo
idx = bisect.bisect_right(lt, ts) - 1
assert idx >= 0
return self._ttinfos[idx]
def _get_local_timestamp(self, dt):
return (
(dt.toordinal() - EPOCHORDINAL) * 86400
+ dt.hour * 3600
+ dt.minute * 60
+ dt.second
)
def __str__(self):
if self._key is not None:
return f"{self._key}"
else:
return repr(self)
def __repr__(self):
if self._key is not None:
return f"{self.__class__.__name__}(key={self._key!r})"
else:
return f"{self.__class__.__name__}.from_file({self._file_repr})"
def __reduce__(self):
return (self.__class__._unpickle, (self._key, self._from_cache))
def _file_reduce(self):
import pickle
raise pickle.PicklingError(
"Cannot pickle a ZoneInfo file created from a file stream."
)
@classmethod
def _unpickle(cls, key, from_cache):
if from_cache:
return cls(key)
else:
return cls.no_cache(key)
def _find_tzfile(self, key):
return _tzpath.find_tzfile(key)
def _load_file(self, fobj):
# Retrieve all the data as it exists in the zoneinfo file
trans_idx, trans_utc, utcoff, isdst, abbr, tz_str = _common.load_data(
fobj
)
# Infer the DST offsets (needed for .dst()) from the data
dstoff = self._utcoff_to_dstoff(trans_idx, utcoff, isdst)
# Convert all the transition times (UTC) into "seconds since 1970-01-01 local time"
trans_local = self._ts_to_local(trans_idx, trans_utc, utcoff)
# Construct `_ttinfo` objects for each transition in the file
_ttinfo_list = [
_ttinfo(
_load_timedelta(utcoffset), _load_timedelta(dstoffset), tzname
)
for utcoffset, dstoffset, tzname in zip(utcoff, dstoff, abbr)
]
self._trans_utc = trans_utc
self._trans_local = trans_local
self._ttinfos = [_ttinfo_list[idx] for idx in trans_idx]
# Find the first non-DST transition
for i in range(len(isdst)):
if not isdst[i]:
self._tti_before = _ttinfo_list[i]
break
else:
if self._ttinfos:
self._tti_before = self._ttinfos[0]
else:
self._tti_before = None
# Set the "fallback" time zone
if tz_str is not None and tz_str != b"":
self._tz_after = _parse_tz_str(tz_str.decode())
else:
if not self._ttinfos and not _ttinfo_list:
raise ValueError("No time zone information found.")
if self._ttinfos:
self._tz_after = self._ttinfos[-1]
else:
self._tz_after = _ttinfo_list[-1]
# Determine if this is a "fixed offset" zone, meaning that the output
# of the utcoffset, dst and tzname functions does not depend on the
# specific datetime passed.
#
# We make three simplifying assumptions here:
#
# 1. If _tz_after is not a _ttinfo, it has transitions that might
# actually occur (it is possible to construct TZ strings that
# specify STD and DST but no transitions ever occur, such as
# AAA0BBB,0/0,J365/25).
# 2. If _ttinfo_list contains more than one _ttinfo object, the objects
# represent different offsets.
# 3. _ttinfo_list contains no unused _ttinfos (in which case an
# otherwise fixed-offset zone with extra _ttinfos defined may
# appear to *not* be a fixed offset zone).
#
# Violations to these assumptions would be fairly exotic, and exotic
# zones should almost certainly not be used with datetime.time (the
# only thing that would be affected by this).
if len(_ttinfo_list) > 1 or not isinstance(self._tz_after, _ttinfo):
self._fixed_offset = False
elif not _ttinfo_list:
self._fixed_offset = True
else:
self._fixed_offset = _ttinfo_list[0] == self._tz_after
@staticmethod
def _utcoff_to_dstoff(trans_idx, utcoffsets, isdsts):
# Now we must transform our ttis and abbrs into `_ttinfo` objects,
# but there is an issue: .dst() must return a timedelta with the
# difference between utcoffset() and the "standard" offset, but
# the "base offset" and "DST offset" are not encoded in the file;
# we can infer what they are from the isdst flag, but it is not
# sufficient to to just look at the last standard offset, because
# occasionally countries will shift both DST offset and base offset.
typecnt = len(isdsts)
dstoffs = [0] * typecnt # Provisionally assign all to 0.
dst_cnt = sum(isdsts)
dst_found = 0
for i in range(1, len(trans_idx)):
if dst_cnt == dst_found:
break
idx = trans_idx[i]
dst = isdsts[idx]
# We're only going to look at daylight saving time
if not dst:
continue
# Skip any offsets that have already been assigned
if dstoffs[idx] != 0:
continue
dstoff = 0
utcoff = utcoffsets[idx]
comp_idx = trans_idx[i - 1]
if not isdsts[comp_idx]:
dstoff = utcoff - utcoffsets[comp_idx]
if not dstoff and idx < (typecnt - 1):
comp_idx = trans_idx[i + 1]
# If the following transition is also DST and we couldn't
# find the DST offset by this point, we're going ot have to
# skip it and hope this transition gets assigned later
if isdsts[comp_idx]:
continue
dstoff = utcoff - utcoffsets[comp_idx]
if dstoff:
dst_found += 1
dstoffs[idx] = dstoff
else:
# If we didn't find a valid value for a given index, we'll end up
# with dstoff = 0 for something where `isdst=1`. This is obviously
# wrong - one hour will be a much better guess than 0
for idx in range(typecnt):
if not dstoffs[idx] and isdsts[idx]:
dstoffs[idx] = 3600
return dstoffs
@staticmethod
def _ts_to_local(trans_idx, trans_list_utc, utcoffsets):
"""Generate number of seconds since 1970 *in the local time*.
This is necessary to easily find the transition times in local time"""
if not trans_list_utc:
return [[], []]
# Start with the timestamps and modify in-place
trans_list_wall = [list(trans_list_utc), list(trans_list_utc)]
if len(utcoffsets) > 1:
offset_0 = utcoffsets[0]
offset_1 = utcoffsets[trans_idx[0]]
if offset_1 > offset_0:
offset_1, offset_0 = offset_0, offset_1
else:
offset_0 = offset_1 = utcoffsets[0]
trans_list_wall[0][0] += offset_0
trans_list_wall[1][0] += offset_1
for i in range(1, len(trans_idx)):
offset_0 = utcoffsets[trans_idx[i - 1]]
offset_1 = utcoffsets[trans_idx[i]]
if offset_1 > offset_0:
offset_1, offset_0 = offset_0, offset_1
trans_list_wall[0][i] += offset_0
trans_list_wall[1][i] += offset_1
return trans_list_wall
class _ttinfo:
__slots__ = ["utcoff", "dstoff", "tzname"]
def __init__(self, utcoff, dstoff, tzname):
self.utcoff = utcoff
self.dstoff = dstoff
self.tzname = tzname
def __eq__(self, other):
return (
self.utcoff == other.utcoff
and self.dstoff == other.dstoff
and self.tzname == other.tzname
)
def __repr__(self): # pragma: nocover
return (
f"{self.__class__.__name__}"
+ f"({self.utcoff}, {self.dstoff}, {self.tzname})"
)
_NO_TTINFO = _ttinfo(None, None, None)
class _TZStr:
__slots__ = (
"std",
"dst",
"start",
"end",
"get_trans_info",
"get_trans_info_fromutc",
"dst_diff",
)
def __init__(
self, std_abbr, std_offset, dst_abbr, dst_offset, start=None, end=None
):
self.dst_diff = dst_offset - std_offset
std_offset = _load_timedelta(std_offset)
self.std = _ttinfo(
utcoff=std_offset, dstoff=_load_timedelta(0), tzname=std_abbr
)
self.start = start
self.end = end
dst_offset = _load_timedelta(dst_offset)
delta = _load_timedelta(self.dst_diff)
self.dst = _ttinfo(utcoff=dst_offset, dstoff=delta, tzname=dst_abbr)
# These are assertions because the constructor should only be called
# by functions that would fail before passing start or end
assert start is not None, "No transition start specified"
assert end is not None, "No transition end specified"
self.get_trans_info = self._get_trans_info
self.get_trans_info_fromutc = self._get_trans_info_fromutc
def transitions(self, year):
start = self.start.year_to_epoch(year)
end = self.end.year_to_epoch(year)
return start, end
def _get_trans_info(self, ts, year, fold):
"""Get the information about the current transition - tti"""
start, end = self.transitions(year)
# With fold = 0, the period (denominated in local time) with the
# smaller offset starts at the end of the gap and ends at the end of
# the fold; with fold = 1, it runs from the start of the gap to the
# beginning of the fold.
#
# So in order to determine the DST boundaries we need to know both
# the fold and whether DST is positive or negative (rare), and it
# turns out that this boils down to fold XOR is_positive.
if fold == (self.dst_diff >= 0):
end -= self.dst_diff
else:
start += self.dst_diff
if start < end:
isdst = start <= ts < end
else:
isdst = not (end <= ts < start)
return self.dst if isdst else self.std
def _get_trans_info_fromutc(self, ts, year):
start, end = self.transitions(year)
start -= self.std.utcoff.total_seconds()
end -= self.dst.utcoff.total_seconds()
if start < end:
isdst = start <= ts < end
else:
isdst = not (end <= ts < start)
# For positive DST, the ambiguous period is one dst_diff after the end
# of DST; for negative DST, the ambiguous period is one dst_diff before
# the start of DST.
if self.dst_diff > 0:
ambig_start = end
ambig_end = end + self.dst_diff
else:
ambig_start = start
ambig_end = start - self.dst_diff
fold = ambig_start <= ts < ambig_end
return (self.dst if isdst else self.std, fold)
def _post_epoch_days_before_year(year):
"""Get the number of days between 1970-01-01 and YEAR-01-01"""
y = year - 1
return y * 365 + y // 4 - y // 100 + y // 400 - EPOCHORDINAL
class _DayOffset:
__slots__ = ["d", "julian", "hour", "minute", "second"]
def __init__(self, d, julian, hour=2, minute=0, second=0):
if not (0 + julian) <= d <= 365:
min_day = 0 + julian
raise ValueError(f"d must be in [{min_day}, 365], not: {d}")
self.d = d
self.julian = julian
self.hour = hour
self.minute = minute
self.second = second
def year_to_epoch(self, year):
days_before_year = _post_epoch_days_before_year(year)
d = self.d
if self.julian and d >= 59 and calendar.isleap(year):
d += 1
epoch = (days_before_year + d) * 86400
epoch += self.hour * 3600 + self.minute * 60 + self.second
return epoch
class _CalendarOffset:
__slots__ = ["m", "w", "d", "hour", "minute", "second"]
_DAYS_BEFORE_MONTH = (
-1,
0,
31,
59,
90,
120,
151,
181,
212,
243,
273,
304,
334,
)
def __init__(self, m, w, d, hour=2, minute=0, second=0):
if not 0 < m <= 12:
raise ValueError("m must be in (0, 12]")
if not 0 < w <= 5:
raise ValueError("w must be in (0, 5]")
if not 0 <= d <= 6:
raise ValueError("d must be in [0, 6]")
self.m = m
self.w = w
self.d = d
self.hour = hour
self.minute = minute
self.second = second
@classmethod
def _ymd2ord(cls, year, month, day):
return (
_post_epoch_days_before_year(year)
+ cls._DAYS_BEFORE_MONTH[month]
+ (month > 2 and calendar.isleap(year))
+ day
)
# TODO: These are not actually epoch dates as they are expressed in local time
def year_to_epoch(self, year):
"""Calculates the datetime of the occurrence from the year"""
# We know year and month, we need to convert w, d into day of month
#
# Week 1 is the first week in which day `d` (where 0 = Sunday) appears.
# Week 5 represents the last occurrence of day `d`, so we need to know
# the range of the month.
first_day, days_in_month = calendar.monthrange(year, self.m)
# This equation seems magical, so I'll break it down:
# 1. calendar says 0 = Monday, POSIX says 0 = Sunday
# so we need first_day + 1 to get 1 = Monday -> 7 = Sunday,
# which is still equivalent because this math is mod 7
# 2. Get first day - desired day mod 7: -1 % 7 = 6, so we don't need
# to do anything to adjust negative numbers.
# 3. Add 1 because month days are a 1-based index.
month_day = (self.d - (first_day + 1)) % 7 + 1
# Now use a 0-based index version of `w` to calculate the w-th
# occurrence of `d`
month_day += (self.w - 1) * 7
# month_day will only be > days_in_month if w was 5, and `w` means
# "last occurrence of `d`", so now we just check if we over-shot the
# end of the month and if so knock off 1 week.
if month_day > days_in_month:
month_day -= 7
ordinal = self._ymd2ord(year, self.m, month_day)
epoch = ordinal * 86400
epoch += self.hour * 3600 + self.minute * 60 + self.second
return epoch
def _parse_tz_str(tz_str):
# The tz string has the format:
#
# std[offset[dst[offset],start[/time],end[/time]]]
#
# std and dst must be 3 or more characters long and must not contain
# a leading colon, embedded digits, commas, nor a plus or minus signs;
# The spaces between "std" and "offset" are only for display and are
# not actually present in the string.
#
# The format of the offset is ``[+|-]hh[:mm[:ss]]``
offset_str, *start_end_str = tz_str.split(",", 1)
# fmt: off
parser_re = re.compile(
r"(?P<std>[^<0-9:.+-]+|<[a-zA-Z0-9+\-]+>)" +
r"((?P<stdoff>[+-]?\d{1,2}(:\d{2}(:\d{2})?)?)" +
r"((?P<dst>[^0-9:.+-]+|<[a-zA-Z0-9+\-]+>)" +
r"((?P<dstoff>[+-]?\d{1,2}(:\d{2}(:\d{2})?)?))?" +
r")?" + # dst
r")?$" # stdoff
)
# fmt: on
m = parser_re.match(offset_str)
if m is None:
raise ValueError(f"{tz_str} is not a valid TZ string")
std_abbr = m.group("std")
dst_abbr = m.group("dst")
dst_offset = None
std_abbr = std_abbr.strip("<>")
if dst_abbr:
dst_abbr = dst_abbr.strip("<>")
std_offset = m.group("stdoff")
if std_offset:
try:
std_offset = _parse_tz_delta(std_offset)
except ValueError as e:
raise ValueError(f"Invalid STD offset in {tz_str}") from e
else:
std_offset = 0
if dst_abbr is not None:
dst_offset = m.group("dstoff")
if dst_offset:
try:
dst_offset = _parse_tz_delta(dst_offset)
except ValueError as e:
raise ValueError(f"Invalid DST offset in {tz_str}") from e
else:
dst_offset = std_offset + 3600
if not start_end_str:
raise ValueError(f"Missing transition rules: {tz_str}")
start_end_strs = start_end_str[0].split(",", 1)
try:
start, end = (_parse_dst_start_end(x) for x in start_end_strs)
except ValueError as e:
raise ValueError(f"Invalid TZ string: {tz_str}") from e
return _TZStr(std_abbr, std_offset, dst_abbr, dst_offset, start, end)
elif start_end_str:
raise ValueError(f"Transition rule present without DST: {tz_str}")
else:
# This is a static ttinfo, don't return _TZStr
return _ttinfo(
_load_timedelta(std_offset), _load_timedelta(0), std_abbr
)
def _parse_dst_start_end(dststr):
date, *time = dststr.split("/")
if date[0] == "M":
n_is_julian = False
m = re.match(r"M(\d{1,2})\.(\d).(\d)$", date)
if m is None:
raise ValueError(f"Invalid dst start/end date: {dststr}")
date_offset = tuple(map(int, m.groups()))
offset = _CalendarOffset(*date_offset)
else:
if date[0] == "J":
n_is_julian = True
date = date[1:]
else:
n_is_julian = False
doy = int(date)
offset = _DayOffset(doy, n_is_julian)
if time:
time_components = list(map(int, time[0].split(":")))
n_components = len(time_components)
if n_components < 3:
time_components.extend([0] * (3 - n_components))
offset.hour, offset.minute, offset.second = time_components
return offset
def _parse_tz_delta(tz_delta):
match = re.match(
r"(?P<sign>[+-])?(?P<h>\d{1,2})(:(?P<m>\d{2})(:(?P<s>\d{2}))?)?",
tz_delta,
)
# Anything passed to this function should already have hit an equivalent
# regular expression to find the section to parse.
assert match is not None, tz_delta
h, m, s = (
int(v) if v is not None else 0
for v in map(match.group, ("h", "m", "s"))
)
total = h * 3600 + m * 60 + s
if not -86400 < total < 86400:
raise ValueError(
"Offset must be strictly between -24h and +24h:" + tz_delta
)
# Yes, +5 maps to an offset of -5h
if match.group("sign") != "-":
total *= -1
return total

View file

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

View file

@ -1,7 +1,7 @@
from __future__ import absolute_import, division, unicode_literals
from bleach.six_shim import text_type
from bleach.six_shim import http_client, urllib
from six import text_type
from six.moves import http_client, urllib
import codecs
import re

View file

@ -1,6 +1,6 @@
from __future__ import absolute_import, division, unicode_literals
from bleach.six_shim import unichr as chr
from six import unichr as chr
from collections import deque, OrderedDict
from sys import version_info

View file

@ -1,5 +1,5 @@
from __future__ import absolute_import, division, unicode_literals
from bleach.six_shim import text_type
from six import text_type
from bisect import bisect_left

View file

@ -7,7 +7,7 @@ try:
except ImportError:
from collections import Mapping
from bleach.six_shim import text_type, PY3
from six import text_type, PY3
if PY3:
import xml.etree.ElementTree as default_etree

View file

@ -1,6 +1,6 @@
from __future__ import absolute_import, division, unicode_literals
from bleach.six_shim import text_type
from six import text_type
from . import base
from ..constants import namespaces, voidElements

View file

@ -12,7 +12,7 @@ import re
import warnings
from xml.sax.saxutils import escape, unescape
from bleach.six_shim import urllib_parse as urlparse
from six.moves import urllib_parse as urlparse
from . import base
from ..constants import namespaces, prefixes

View file

@ -1,5 +1,5 @@
from __future__ import absolute_import, division, unicode_literals
from bleach.six_shim import viewkeys
from six import with_metaclass, viewkeys
import types
@ -423,7 +423,7 @@ def getPhases(debug):
return type
# pylint:disable=unused-argument
class Phase(metaclass=getMetaclass(debug, log)):
class Phase(with_metaclass(getMetaclass(debug, log))):
"""Base class for helper object that implements each phase of processing
"""
__slots__ = ("parser", "tree", "__startTagCache", "__endTagCache")

View file

@ -1,5 +1,5 @@
from __future__ import absolute_import, division, unicode_literals
from bleach.six_shim import text_type
from six import text_type
import re

View file

@ -1,5 +1,5 @@
from __future__ import absolute_import, division, unicode_literals
from bleach.six_shim import text_type
from six import text_type
from ..constants import scopingElements, tableInsertModeElements, namespaces

View file

@ -1,7 +1,7 @@
from __future__ import absolute_import, division, unicode_literals
# pylint:disable=protected-access
from bleach.six_shim import text_type
from six import text_type
import re

View file

@ -28,7 +28,7 @@ from . import etree as etree_builders
from .. import _ihatexml
import lxml.etree as etree
from bleach.six_shim import PY3, binary_type
from six import PY3, binary_type
fullTree = True

View file

@ -3,7 +3,7 @@ from __future__ import absolute_import, division, unicode_literals
from collections import OrderedDict
import re
from bleach.six_shim import string_types
from six import string_types
from . import base
from .._utils import moduleFactoryFactory

View file

@ -1,5 +1,5 @@
from __future__ import absolute_import, division, unicode_literals
from bleach.six_shim import text_type
from six import text_type
from collections import OrderedDict

View file

@ -7,12 +7,8 @@ set -o pipefail
BLEACH_VENDOR_DIR=${BLEACH_VENDOR_DIR:-"."}
DEST=${DEST:-"."}
# Install with no dependencies
pip install --no-binary all --no-compile --no-deps -r "${BLEACH_VENDOR_DIR}/vendor.txt" --target "${DEST}"
# Apply patches
(cd "${DEST}" && patch -p2 < 01_html5lib_six.patch)
# install Python 3.6.14 urllib.urlparse for #536
curl --proto '=https' --tlsv1.2 -o "${DEST}/parse.py" https://raw.githubusercontent.com/python/cpython/v3.6.14/Lib/urllib/parse.py
(cd "${DEST}" && sha256sum parse.py > parse.py.SHA256SUM)

View file

@ -395,26 +395,10 @@ class BleachHTMLTokenizer(HTMLTokenizer):
# followed by a series of characters. It's treated as a tag
# name that abruptly ends, but we should treat that like
# character data
yield {"type": TAG_TOKEN_TYPE_CHARACTERS, "data": self.stream.get_tag()}
elif last_error_token["data"] in (
"duplicate-attribute",
"eof-in-attribute-name",
"eof-in-attribute-value-no-quotes",
"expected-end-of-tag-but-got-eof",
):
# Handle the case where the text being parsed ends with <
# followed by characters and then space and then:
#
# * more characters
# * more characters repeated with a space between (e.g. "abc abc")
# * more characters and then a space and then an EOF (e.g. "abc def ")
#
# These cases are treated as a tag name followed by an
# attribute that abruptly ends, but we should treat that like
# character data instead.
yield {"type": TAG_TOKEN_TYPE_CHARACTERS, "data": self.stream.get_tag()}
yield {
"type": TAG_TOKEN_TYPE_CHARACTERS,
"data": "<" + self.currentToken["name"],
}
else:
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.
\b(?<![@.])(?:(?:{0}):/{{0,3}}(?:(?:\w+:)?\w+@)?)? # http://
([\w-]+\.)+(?:{1})(?:\:[0-9]+)?(?!\.\w)\b # xx.yy.tld(:##)?
(?:[/?][^\s\{{\}}\|\\\^`<>"]*)?
# /path/zz (excluding "unsafe" chars from RFC 3986,
(?:[/?][^\s\{{\}}\|\\\^\[\]`<>"]*)?
# /path/zz (excluding "unsafe" chars from RFC 1738,
# except for # and ~, which happen in practice)
""".format(
"|".join(sorted(protocols)), "|".join(sorted(tlds))
@ -591,7 +591,7 @@ class LinkifyFilter(html5lib_shim.Filter):
in_a = False
token_buffer = []
else:
token_buffer.extend(list(self.extract_entities(token)))
token_buffer.append(token)
continue
if token["type"] in ["StartTag", "EmptyTag"]:

View file

@ -1,19 +0,0 @@
"""
Replacement module for what html5lib uses six for.
"""
import http.client
import operator
import urllib
PY3 = True
binary_type = bytes
string_types = (str,)
text_type = str
unichr = chr
viewkeys = operator.methodcaller("keys")
http_client = http.client
urllib = urllib
urllib_parse = urllib.parse

View file

@ -15,8 +15,8 @@ documentation: http://www.crummy.com/software/BeautifulSoup/bs4/doc/
"""
__author__ = "Leonard Richardson (leonardr@segfault.org)"
__version__ = "4.12.3"
__copyright__ = "Copyright (c) 2004-2024 Leonard Richardson"
__version__ = "4.11.2"
__copyright__ = "Copyright (c) 2004-2023 Leonard Richardson"
# Use of this source code is governed by the MIT license.
__license__ = "MIT"
@ -38,13 +38,11 @@ from .builder import (
builder_registry,
ParserRejectedMarkup,
XMLParsedAsHTMLWarning,
HTMLParserTreeBuilder
)
from .dammit import UnicodeDammit
from .element import (
CData,
Comment,
CSS,
DEFAULT_OUTPUT_ENCODING,
Declaration,
Doctype,
@ -118,7 +116,7 @@ class BeautifulSoup(Tag):
ASCII_SPACES = '\x20\x0a\x09\x0c\x0d'
NO_PARSER_SPECIFIED_WARNING = "No parser was explicitly specified, so I'm using the best available %(markup_type)s parser for this system (\"%(parser)s\"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.\n\nThe code that caused this warning is on line %(line_number)s of the file %(filename)s. To get rid of this warning, pass the additional argument 'features=\"%(parser)s\"' to the BeautifulSoup constructor.\n"
def __init__(self, markup="", features=None, builder=None,
parse_only=None, from_encoding=None, exclude_encodings=None,
element_classes=None, **kwargs):
@ -350,49 +348,25 @@ class BeautifulSoup(Tag):
self.markup = None
self.builder.soup = None
def _clone(self):
"""Create a new BeautifulSoup object with the same TreeBuilder,
but not associated with any markup.
def __copy__(self):
"""Copy a BeautifulSoup object by converting the document to a string and parsing it again."""
copy = type(self)(
self.encode('utf-8'), builder=self.builder, from_encoding='utf-8'
)
This is the first step of the deepcopy process.
"""
clone = type(self)("", None, self.builder)
# Although we encoded the tree to UTF-8, that may not have
# been the encoding of the original markup. Set the copy's
# .original_encoding to reflect the original object's
# .original_encoding.
copy.original_encoding = self.original_encoding
return copy
# Keep track of the encoding of the original document,
# since we won't be parsing it again.
clone.original_encoding = self.original_encoding
return clone
def __getstate__(self):
# Frequently a tree builder can't be pickled.
d = dict(self.__dict__)
if 'builder' in d and d['builder'] is not None and not self.builder.picklable:
d['builder'] = type(self.builder)
# Store the contents as a Unicode string.
d['contents'] = []
d['markup'] = self.decode()
# If _most_recent_element is present, it's a Tag object left
# over from initial parse. It might not be picklable and we
# don't need it.
if '_most_recent_element' in d:
del d['_most_recent_element']
d['builder'] = None
return d
def __setstate__(self, state):
# If necessary, restore the TreeBuilder by looking it up.
self.__dict__ = state
if isinstance(self.builder, type):
self.builder = self.builder()
elif not self.builder:
# We don't know which builder was used to build this
# parse tree, so use a default we know is always available.
self.builder = HTMLParserTreeBuilder()
self.builder.soup = self
self.reset()
self._feed()
return state
@classmethod
def _decode_markup(cls, markup):
@ -494,7 +468,6 @@ class BeautifulSoup(Tag):
self.open_tag_counter = Counter()
self.preserve_whitespace_tag_stack = []
self.string_container_stack = []
self._most_recent_element = None
self.pushTag(self)
def new_tag(self, name, namespace=None, nsprefix=None, attrs={},
@ -776,7 +749,7 @@ class BeautifulSoup(Tag):
def decode(self, pretty_print=False,
eventual_encoding=DEFAULT_OUTPUT_ENCODING,
formatter="minimal", iterator=None):
formatter="minimal"):
"""Returns a string or Unicode representation of the parse tree
as an HTML or XML document.
@ -803,7 +776,7 @@ class BeautifulSoup(Tag):
else:
indent_level = 0
return prefix + super(BeautifulSoup, self).decode(
indent_level, eventual_encoding, formatter, iterator)
indent_level, eventual_encoding, formatter)
# Aliases to make it easier to get started quickly, e.g. 'from bs4 import _soup'
_s = BeautifulSoup

View file

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

View file

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

View file

@ -24,7 +24,6 @@ from bs4.dammit import EntitySubstitution, UnicodeDammit
from bs4.builder import (
DetectsXMLParsedAsHTML,
ParserRejectedMarkup,
HTML,
HTMLTreeBuilder,
STRICT,
@ -71,22 +70,6 @@ class BeautifulSoupHTMLParser(HTMLParser, DetectsXMLParsedAsHTML):
self._initialize_xml_detector()
def error(self, message):
# NOTE: This method is required so long as Python 3.9 is
# supported. The corresponding code is removed from HTMLParser
# in 3.5, but not removed from ParserBase until 3.10.
# https://github.com/python/cpython/issues/76025
#
# The original implementation turned the error into a warning,
# but in every case I discovered, this made HTMLParser
# immediately crash with an error message that was less
# helpful than the warning. The new implementation makes it
# more clear that html.parser just can't parse this
# markup. The 3.10 implementation does the same, though it
# raises AssertionError rather than calling a method. (We
# catch this error and wrap it in a ParserRejectedMarkup.)
raise ParserRejectedMarkup(message)
def handle_startendtag(self, name, attrs):
"""Handle an incoming empty-element tag.
@ -376,12 +359,6 @@ class HTMLParserTreeBuilder(HTMLTreeBuilder):
args, kwargs = self.parser_args
parser = BeautifulSoupHTMLParser(*args, **kwargs)
parser.soup = self.soup
try:
parser.feed(markup)
parser.close()
except AssertionError as e:
# html.parser raises AssertionError in rare cases to
# indicate a fatal problem with the markup, especially
# when there's an error in the doctype declaration.
raise ParserRejectedMarkup(e)
parser.feed(markup)
parser.close()
parser.already_closed_empty_element = []

View file

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

View file

@ -1,280 +0,0 @@
"""Integration code for CSS selectors using Soup Sieve (pypi: soupsieve)."""
import warnings
try:
import soupsieve
except ImportError as e:
soupsieve = None
warnings.warn(
'The soupsieve package is not installed. CSS selectors cannot be used.'
)
class CSS(object):
"""A proxy object against the soupsieve library, to simplify its
CSS selector API.
Acquire this object through the .css attribute on the
BeautifulSoup object, or on the Tag you want to use as the
starting point for a CSS selector.
The main advantage of doing this is that the tag to be selected
against doesn't need to be explicitly specified in the function
calls, since it's already scoped to a tag.
"""
def __init__(self, tag, api=soupsieve):
"""Constructor.
You don't need to instantiate this class yourself; instead,
access the .css attribute on the BeautifulSoup object, or on
the Tag you want to use as the starting point for your CSS
selector.
:param tag: All CSS selectors will use this as their starting
point.
:param api: A plug-in replacement for the soupsieve module,
designed mainly for use in tests.
"""
if api is None:
raise NotImplementedError(
"Cannot execute CSS selectors because the soupsieve package is not installed."
)
self.api = api
self.tag = tag
def escape(self, ident):
"""Escape a CSS identifier.
This is a simple wrapper around soupselect.escape(). See the
documentation for that function for more information.
"""
if soupsieve is None:
raise NotImplementedError(
"Cannot escape CSS identifiers because the soupsieve package is not installed."
)
return self.api.escape(ident)
def _ns(self, ns, select):
"""Normalize a dictionary of namespaces."""
if not isinstance(select, self.api.SoupSieve) and ns is None:
# If the selector is a precompiled pattern, it already has
# a namespace context compiled in, which cannot be
# replaced.
ns = self.tag._namespaces
return ns
def _rs(self, results):
"""Normalize a list of results to a Resultset.
A ResultSet is more consistent with the rest of Beautiful
Soup's API, and ResultSet.__getattr__ has a helpful error
message if you try to treat a list of results as a single
result (a common mistake).
"""
# Import here to avoid circular import
from bs4.element import ResultSet
return ResultSet(None, results)
def compile(self, select, namespaces=None, flags=0, **kwargs):
"""Pre-compile a selector and return the compiled object.
:param selector: A CSS selector.
:param namespaces: A dictionary mapping namespace prefixes
used in the CSS selector to namespace URIs. By default,
Beautiful Soup will use the prefixes it encountered while
parsing the document.
:param flags: Flags to be passed into Soup Sieve's
soupsieve.compile() method.
:param kwargs: Keyword arguments to be passed into SoupSieve's
soupsieve.compile() method.
:return: A precompiled selector object.
:rtype: soupsieve.SoupSieve
"""
return self.api.compile(
select, self._ns(namespaces, select), flags, **kwargs
)
def select_one(self, select, namespaces=None, flags=0, **kwargs):
"""Perform a CSS selection operation on the current Tag and return the
first result.
This uses the Soup Sieve library. For more information, see
that library's documentation for the soupsieve.select_one()
method.
:param selector: A CSS selector.
:param namespaces: A dictionary mapping namespace prefixes
used in the CSS selector to namespace URIs. By default,
Beautiful Soup will use the prefixes it encountered while
parsing the document.
:param flags: Flags to be passed into Soup Sieve's
soupsieve.select_one() method.
:param kwargs: Keyword arguments to be passed into SoupSieve's
soupsieve.select_one() method.
:return: A Tag, or None if the selector has no match.
:rtype: bs4.element.Tag
"""
return self.api.select_one(
select, self.tag, self._ns(namespaces, select), flags, **kwargs
)
def select(self, select, namespaces=None, limit=0, flags=0, **kwargs):
"""Perform a CSS selection operation on the current Tag.
This uses the Soup Sieve library. For more information, see
that library's documentation for the soupsieve.select()
method.
:param selector: A string containing a CSS selector.
:param namespaces: A dictionary mapping namespace prefixes
used in the CSS selector to namespace URIs. By default,
Beautiful Soup will pass in the prefixes it encountered while
parsing the document.
:param limit: After finding this number of results, stop looking.
:param flags: Flags to be passed into Soup Sieve's
soupsieve.select() method.
:param kwargs: Keyword arguments to be passed into SoupSieve's
soupsieve.select() method.
:return: A ResultSet of Tag objects.
:rtype: bs4.element.ResultSet
"""
if limit is None:
limit = 0
return self._rs(
self.api.select(
select, self.tag, self._ns(namespaces, select), limit, flags,
**kwargs
)
)
def iselect(self, select, namespaces=None, limit=0, flags=0, **kwargs):
"""Perform a CSS selection operation on the current Tag.
This uses the Soup Sieve library. For more information, see
that library's documentation for the soupsieve.iselect()
method. It is the same as select(), but it returns a generator
instead of a list.
:param selector: A string containing a CSS selector.
:param namespaces: A dictionary mapping namespace prefixes
used in the CSS selector to namespace URIs. By default,
Beautiful Soup will pass in the prefixes it encountered while
parsing the document.
:param limit: After finding this number of results, stop looking.
:param flags: Flags to be passed into Soup Sieve's
soupsieve.iselect() method.
:param kwargs: Keyword arguments to be passed into SoupSieve's
soupsieve.iselect() method.
:return: A generator
:rtype: types.GeneratorType
"""
return self.api.iselect(
select, self.tag, self._ns(namespaces, select), limit, flags, **kwargs
)
def closest(self, select, namespaces=None, flags=0, **kwargs):
"""Find the Tag closest to this one that matches the given selector.
This uses the Soup Sieve library. For more information, see
that library's documentation for the soupsieve.closest()
method.
:param selector: A string containing a CSS selector.
:param namespaces: A dictionary mapping namespace prefixes
used in the CSS selector to namespace URIs. By default,
Beautiful Soup will pass in the prefixes it encountered while
parsing the document.
:param flags: Flags to be passed into Soup Sieve's
soupsieve.closest() method.
:param kwargs: Keyword arguments to be passed into SoupSieve's
soupsieve.closest() method.
:return: A Tag, or None if there is no match.
:rtype: bs4.Tag
"""
return self.api.closest(
select, self.tag, self._ns(namespaces, select), flags, **kwargs
)
def match(self, select, namespaces=None, flags=0, **kwargs):
"""Check whether this Tag matches the given CSS selector.
This uses the Soup Sieve library. For more information, see
that library's documentation for the soupsieve.match()
method.
:param: a CSS selector.
:param namespaces: A dictionary mapping namespace prefixes
used in the CSS selector to namespace URIs. By default,
Beautiful Soup will pass in the prefixes it encountered while
parsing the document.
:param flags: Flags to be passed into Soup Sieve's
soupsieve.match() method.
:param kwargs: Keyword arguments to be passed into SoupSieve's
soupsieve.match() method.
:return: True if this Tag matches the selector; False otherwise.
:rtype: bool
"""
return self.api.match(
select, self.tag, self._ns(namespaces, select), flags, **kwargs
)
def filter(self, select, namespaces=None, flags=0, **kwargs):
"""Filter this Tag's direct children based on the given CSS selector.
This uses the Soup Sieve library. It works the same way as
passing this Tag into that library's soupsieve.filter()
method. More information, for more information see the
documentation for soupsieve.filter().
:param namespaces: A dictionary mapping namespace prefixes
used in the CSS selector to namespace URIs. By default,
Beautiful Soup will pass in the prefixes it encountered while
parsing the document.
:param flags: Flags to be passed into Soup Sieve's
soupsieve.filter() method.
:param kwargs: Keyword arguments to be passed into SoupSieve's
soupsieve.filter() method.
:return: A ResultSet of Tag objects.
:rtype: bs4.element.ResultSet
"""
return self._rs(
self.api.filter(
select, self.tag, self._ns(namespaces, select), flags, **kwargs
)
)

View file

@ -59,6 +59,21 @@ def diagnose(data):
if hasattr(data, 'read'):
data = data.read()
elif data.startswith("http:") or data.startswith("https:"):
print(('"%s" looks like a URL. Beautiful Soup is not an HTTP client.' % data))
print("You need to use some other library to get the document behind the URL, and feed that document to Beautiful Soup.")
return
else:
try:
if os.path.exists(data):
print(('"%s" looks like a filename. Reading data from the file.' % data))
with open(data) as fp:
data = fp.read()
except ValueError:
# This can happen on some platforms when the 'filename' is
# too long. Assume it's data and not a filename.
pass
print("")
for parser in basic_parsers:
print(("Trying to parse your markup with %s" % parser))

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