diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ad54157..26dda362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 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: @@ -8,7 +30,7 @@ * UI: * Fix: macOS platform capitalization. * Other: - * Fix: Remove deprecated getdefaultlocale (Thanks @teodorstelian) (#2364, #2345) + * Fix: Remove deprecated getdefaultlocale. (Thanks @teodorstelian) (#2364, #2345) ## v2.14.3 (2024-06-19) diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css index f8b15890..2835488d 100644 --- a/data/interfaces/default/css/tautulli.css +++ b/data/interfaces/default/css/tautulli.css @@ -1478,7 +1478,8 @@ a:hover .dashboard-stats-square { text-align: center; position: relative; z-index: 0; - overflow: hidden; + overflow: auto; + scrollbar-width: none; } .dashboard-recent-media { width: 100%; diff --git a/data/interfaces/default/index.html b/data/interfaces/default/index.html index 6e4818c7..582ac93f 100644 --- a/data/interfaces/default/index.html +++ b/data/interfaces/default/index.html @@ -92,10 +92,10 @@

Recently Added

@@ -936,10 +936,14 @@ 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(); - highlightAddedScrollerButton(); + highlightScrollerButton("#recently-added"); + paginateScroller("#recently-added", ".paginate-added"); } }); } @@ -955,57 +959,11 @@ 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); }); @@ -1013,7 +971,6 @@ $('#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); }); diff --git a/data/interfaces/default/js/script.js b/data/interfaces/default/js/script.js index 2df5daeb..5d75c69d 100644 --- a/data/interfaces/default/js/script.js +++ b/data/interfaces/default/js/script.js @@ -360,7 +360,8 @@ function humanDuration(ms, sig='dhm', units='ms', return_seconds=300000) { sig = 'dhms' } - ms = ms * factors[units]; + r = factors[sig.slice(-1)]; + ms = Math.round(ms * factors[units] / r) * r; h = ms % factors['d']; d = Math.trunc(ms / factors['d']); @@ -929,3 +930,50 @@ $('.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"); + } + }); +} diff --git a/data/interfaces/default/library.html b/data/interfaces/default/library.html index b1fe8b6f..ba61153d 100644 --- a/data/interfaces/default/library.html +++ b/data/interfaces/default/library.html @@ -149,10 +149,10 @@ DOCUMENTATION :: END
@@ -175,10 +175,10 @@ DOCUMENTATION :: END
@@ -690,7 +690,8 @@ DOCUMENTATION :: END }, complete: function(xhr, status) { $("#library-recently-watched").html(xhr.responseText); - highlightWatchedScrollerButton(); + highlightScrollerButton("#recently-watched"); + paginateScroller("#recently-watched", ".paginate-watched"); } }); } @@ -706,7 +707,8 @@ DOCUMENTATION :: END }, complete: function(xhr, status) { $("#library-recently-added").html(xhr.responseText); - highlightAddedScrollerButton(); + highlightScrollerButton("#recently-added"); + paginateScroller("#recently-added", ".paginate-added"); } }); } @@ -716,83 +718,8 @@ 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 diff --git a/data/interfaces/default/library_recently_added.html b/data/interfaces/default/library_recently_added.html index fb53f9a5..4cf56d8e 100644 --- a/data/interfaces/default/library_recently_added.html +++ b/data/interfaces/default/library_recently_added.html @@ -36,7 +36,7 @@ DOCUMENTATION :: END %>
-
+
    % for item in data:
  • diff --git a/data/interfaces/default/newsletter_config.html b/data/interfaces/default/newsletter_config.html index 10583707..3c1c68ee 100644 --- a/data/interfaces/default/newsletter_config.html +++ b/data/interfaces/default/newsletter_config.html @@ -50,7 +50,10 @@

Set the schedule for the newsletter. - Set the schedule for the newsletter using a custom crontab. Only standard cron values are valid. + + Set the schedule for the newsletter using a custom crontab. + Click here for a list of supported expressions. +

@@ -481,7 +484,7 @@ }); if (${newsletter['config']['custom_cron']}) { - $('#cron_value').val('${newsletter['cron']}'); + $('#cron_value').val('${newsletter['cron'] | n}'); } else { try { cron_widget.cron('value', '${newsletter['cron']}'); diff --git a/data/interfaces/default/recently_added.html b/data/interfaces/default/recently_added.html index 52920817..74d5e561 100644 --- a/data/interfaces/default/recently_added.html +++ b/data/interfaces/default/recently_added.html @@ -36,7 +36,7 @@ DOCUMENTATION :: END %> % if data:
-
+
    % for item in data:
    diff --git a/data/interfaces/default/user.html b/data/interfaces/default/user.html index 6ec74620..adfd6c39 100644 --- a/data/interfaces/default/user.html +++ b/data/interfaces/default/user.html @@ -125,10 +125,10 @@ DOCUMENTATION :: END
    @@ -666,52 +666,14 @@ DOCUMENTATION :: END }, complete: function(xhr, status) { $("#user-recently-watched").html(xhr.responseText); - highlightWatchedScrollerButton(); + highlightScrollerButton("#recently-watched"); + paginateScroller("#recently-watched", ".paginate-watched"); } }); } 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; diff --git a/data/interfaces/default/user_recently_watched.html b/data/interfaces/default/user_recently_watched.html index 39243766..b9f8a8a1 100644 --- a/data/interfaces/default/user_recently_watched.html +++ b/data/interfaces/default/user_recently_watched.html @@ -31,7 +31,7 @@ DOCUMENTATION :: END from plexpy.helpers import page, short_season %>
    -
    +
      % for item in data:
    • diff --git a/lib/certifi/__init__.py b/lib/certifi/__init__.py index d321f1bc..f61d77fa 100644 --- a/lib/certifi/__init__.py +++ b/lib/certifi/__init__.py @@ -1,4 +1,4 @@ from .core import contents, where __all__ = ["contents", "where"] -__version__ = "2024.07.04" +__version__ = "2024.08.30" diff --git a/lib/certifi/cacert.pem b/lib/certifi/cacert.pem index a6581589..3c165a1b 100644 --- a/lib/certifi/cacert.pem +++ b/lib/certifi/cacert.pem @@ -4796,3 +4796,134 @@ PQQDAwNoADBlAjAdfKR7w4l1M+E7qUW/Runpod3JIha3RxEL2Jq68cgLcFBTApFw hVmpHqTm6iMxoAACMQD94vizrxa5HnPEluPBMBnYfubDl94cT7iJLzPrSA8Z94dG XSaQpYXFuXqUPoeovQA= -----END CERTIFICATE----- + +# Issuer: CN=TWCA CYBER Root CA O=TAIWAN-CA OU=Root CA +# Subject: CN=TWCA CYBER Root CA O=TAIWAN-CA OU=Root CA +# Label: "TWCA CYBER Root CA" +# Serial: 85076849864375384482682434040119489222 +# MD5 Fingerprint: 0b:33:a0:97:52:95:d4:a9:fd:bb:db:6e:a3:55:5b:51 +# SHA1 Fingerprint: f6:b1:1c:1a:83:38:e9:7b:db:b3:a8:c8:33:24:e0:2d:9c:7f:26:66 +# SHA256 Fingerprint: 3f:63:bb:28:14:be:17:4e:c8:b6:43:9c:f0:8d:6d:56:f0:b7:c4:05:88:3a:56:48:a3:34:42:4d:6b:3e:c5:58 +-----BEGIN CERTIFICATE----- +MIIFjTCCA3WgAwIBAgIQQAE0jMIAAAAAAAAAATzyxjANBgkqhkiG9w0BAQwFADBQ +MQswCQYDVQQGEwJUVzESMBAGA1UEChMJVEFJV0FOLUNBMRAwDgYDVQQLEwdSb290 +IENBMRswGQYDVQQDExJUV0NBIENZQkVSIFJvb3QgQ0EwHhcNMjIxMTIyMDY1NDI5 +WhcNNDcxMTIyMTU1OTU5WjBQMQswCQYDVQQGEwJUVzESMBAGA1UEChMJVEFJV0FO +LUNBMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJUV0NBIENZQkVSIFJvb3Qg +Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDG+Moe2Qkgfh1sTs6P +40czRJzHyWmqOlt47nDSkvgEs1JSHWdyKKHfi12VCv7qze33Kc7wb3+szT3vsxxF +avcokPFhV8UMxKNQXd7UtcsZyoC5dc4pztKFIuwCY8xEMCDa6pFbVuYdHNWdZsc/ +34bKS1PE2Y2yHer43CdTo0fhYcx9tbD47nORxc5zb87uEB8aBs/pJ2DFTxnk684i +JkXXYJndzk834H/nY62wuFm40AZoNWDTNq5xQwTxaWV4fPMf88oon1oglWa0zbfu +j3ikRRjpJi+NmykosaS3Om251Bw4ckVYsV7r8Cibt4LK/c/WMw+f+5eesRycnupf +Xtuq3VTpMCEobY5583WSjCb+3MX2w7DfRFlDo7YDKPYIMKoNM+HvnKkHIuNZW0CP +2oi3aQiotyMuRAlZN1vH4xfyIutuOVLF3lSnmMlLIJXcRolftBL5hSmO68gnFSDA +S9TMfAxsNAwmmyYxpjyn9tnQS6Jk/zuZQXLB4HCX8SS7K8R0IrGsayIyJNN4KsDA +oS/xUgXJP+92ZuJF2A09rZXIx4kmyA+upwMu+8Ff+iDhcK2wZSA3M2Cw1a/XDBzC +kHDXShi8fgGwsOsVHkQGzaRP6AzRwyAQ4VRlnrZR0Bp2a0JaWHY06rc3Ga4udfmW +5cFZ95RXKSWNOkyrTZpB0F8mAwIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBSdhWEUfMFib5do5E83QOGt4A1WNzAd +BgNVHQ4EFgQUnYVhFHzBYm+XaORPN0DhreANVjcwDQYJKoZIhvcNAQEMBQADggIB +AGSPesRiDrWIzLjHhg6hShbNcAu3p4ULs3a2D6f/CIsLJc+o1IN1KriWiLb73y0t +tGlTITVX1olNc79pj3CjYcya2x6a4CD4bLubIp1dhDGaLIrdaqHXKGnK/nZVekZn +68xDiBaiA9a5F/gZbG0jAn/xX9AKKSM70aoK7akXJlQKTcKlTfjF/biBzysseKNn +TKkHmvPfXvt89YnNdJdhEGoHK4Fa0o635yDRIG4kqIQnoVesqlVYL9zZyvpoBJ7t +RCT5dEA7IzOrg1oYJkK2bVS1FmAwbLGg+LhBoF1JSdJlBTrq/p1hvIbZv97Tujqx +f36SNI7JAG7cmL3c7IAFrQI932XtCwP39xaEBDG6k5TY8hL4iuO/Qq+n1M0RFxbI +Qh0UqEL20kCGoE8jypZFVmAGzbdVAaYBlGX+bgUJurSkquLvWL69J1bY73NxW0Qz +8ppy6rBePm6pUlvscG21h483XjyMnM7k8M4MZ0HMzvaAq07MTFb1wWFZk7Q+ptq4 +NxKfKjLji7gh7MMrZQzvIt6IKTtM1/r+t+FHvpw+PoP7UV31aPcuIYXcv/Fa4nzX +xeSDwWrruoBa3lwtcHb4yOWHh8qgnaHlIhInD0Q9HWzq1MKLL295q39QpsQZp6F6 +t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X +-----END CERTIFICATE----- + +# Issuer: CN=SecureSign Root CA12 O=Cybertrust Japan Co., Ltd. +# Subject: CN=SecureSign Root CA12 O=Cybertrust Japan Co., Ltd. +# Label: "SecureSign Root CA12" +# Serial: 587887345431707215246142177076162061960426065942 +# MD5 Fingerprint: c6:89:ca:64:42:9b:62:08:49:0b:1e:7f:e9:07:3d:e8 +# SHA1 Fingerprint: 7a:22:1e:3d:de:1b:06:ac:9e:c8:47:70:16:8e:3c:e5:f7:6b:06:f4 +# SHA256 Fingerprint: 3f:03:4b:b5:70:4d:44:b2:d0:85:45:a0:20:57:de:93:eb:f3:90:5f:ce:72:1a:cb:c7:30:c0:6d:da:ee:90:4e +-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIUZvnHwa/swlG07VOX5uaCwysckBYwDQYJKoZIhvcNAQEL +BQAwUTELMAkGA1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28u +LCBMdGQuMR0wGwYDVQQDExRTZWN1cmVTaWduIFJvb3QgQ0ExMjAeFw0yMDA0MDgw +NTM2NDZaFw00MDA0MDgwNTM2NDZaMFExCzAJBgNVBAYTAkpQMSMwIQYDVQQKExpD +eWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2VjdXJlU2lnbiBS +b290IENBMTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6OcE3emhF +KxS06+QT61d1I02PJC0W6K6OyX2kVzsqdiUzg2zqMoqUm048luT9Ub+ZyZN+v/mt +p7JIKwccJ/VMvHASd6SFVLX9kHrko+RRWAPNEHl57muTH2SOa2SroxPjcf59q5zd +J1M3s6oYwlkm7Fsf0uZlfO+TvdhYXAvA42VvPMfKWeP+bl+sg779XSVOKik71gur +FzJ4pOE+lEa+Ym6b3kaosRbnhW70CEBFEaCeVESE99g2zvVQR9wsMJvuwPWW0v4J +hscGWa5Pro4RmHvzC1KqYiaqId+OJTN5lxZJjfU+1UefNzFJM3IFTQy2VYzxV4+K +h9GtxRESOaCtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD +AgEGMB0GA1UdDgQWBBRXNPN0zwRL1SXm8UC2LEzZLemgrTANBgkqhkiG9w0BAQsF +AAOCAQEAPrvbFxbS8hQBICw4g0utvsqFepq2m2um4fylOqyttCg6r9cBg0krY6Ld +mmQOmFxv3Y67ilQiLUoT865AQ9tPkbeGGuwAtEGBpE/6aouIs3YIcipJQMPTw4WJ +mBClnW8Zt7vPemVV2zfrPIpyMpcemik+rY3moxtt9XUa5rBouVui7mlHJzWhhpmA +8zNL4WukJsPvdFlseqJkth5Ew1DgDzk9qTPxpfPSvWKErI4cqc1avTc7bgoitPQV +55FYxTpE05Uo2cBl6XLK0A+9H7MV2anjpEcJnuDLN/v9vZfVvhgaaaI5gdka9at/ +yOPiZwud9AzqVN/Ssq+xIvEg37xEHA== +-----END CERTIFICATE----- + +# Issuer: CN=SecureSign Root CA14 O=Cybertrust Japan Co., Ltd. +# Subject: CN=SecureSign Root CA14 O=Cybertrust Japan Co., Ltd. +# Label: "SecureSign Root CA14" +# Serial: 575790784512929437950770173562378038616896959179 +# MD5 Fingerprint: 71:0d:72:fa:92:19:65:5e:89:04:ac:16:33:f0:bc:d5 +# SHA1 Fingerprint: dd:50:c0:f7:79:b3:64:2e:74:a2:b8:9d:9f:d3:40:dd:bb:f0:f2:4f +# SHA256 Fingerprint: 4b:00:9c:10:34:49:4f:9a:b5:6b:ba:3b:a1:d6:27:31:fc:4d:20:d8:95:5a:dc:ec:10:a9:25:60:72:61:e3:38 +-----BEGIN CERTIFICATE----- +MIIFcjCCA1qgAwIBAgIUZNtaDCBO6Ncpd8hQJ6JaJ90t8sswDQYJKoZIhvcNAQEM +BQAwUTELMAkGA1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28u +LCBMdGQuMR0wGwYDVQQDExRTZWN1cmVTaWduIFJvb3QgQ0ExNDAeFw0yMDA0MDgw +NzA2MTlaFw00NTA0MDgwNzA2MTlaMFExCzAJBgNVBAYTAkpQMSMwIQYDVQQKExpD +eWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2VjdXJlU2lnbiBS +b290IENBMTQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDF0nqh1oq/ +FjHQmNE6lPxauG4iwWL3pwon71D2LrGeaBLwbCRjOfHw3xDG3rdSINVSW0KZnvOg +vlIfX8xnbacuUKLBl422+JX1sLrcneC+y9/3OPJH9aaakpUqYllQC6KxNedlsmGy +6pJxaeQp8E+BgQQ8sqVb1MWoWWd7VRxJq3qdwudzTe/NCcLEVxLbAQ4jeQkHO6Lo +/IrPj8BGJJw4J+CDnRugv3gVEOuGTgpa/d/aLIJ+7sr2KeH6caH3iGicnPCNvg9J +kdjqOvn90Ghx2+m1K06Ckm9mH+Dw3EzsytHqunQG+bOEkJTRX45zGRBdAuVwpcAQ +0BB8b8VYSbSwbprafZX1zNoCr7gsfXmPvkPx+SgojQlD+Ajda8iLLCSxjVIHvXib +y8posqTdDEx5YMaZ0ZPxMBoH064iwurO8YQJzOAUbn8/ftKChazcqRZOhaBgy/ac +18izju3Gm5h1DVXoX+WViwKkrkMpKBGk5hIwAUt1ax5mnXkvpXYvHUC0bcl9eQjs +0Wq2XSqypWa9a4X0dFbD9ed1Uigspf9mR6XU/v6eVL9lfgHWMI+lNpyiUBzuOIAB +SMbHdPTGrMNASRZhdCyvjG817XsYAFs2PJxQDcqSMxDxJklt33UkN4Ii1+iW/RVL +ApY+B3KVfqs9TC7XyvDf4Fg/LS8EmjijAQIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUBpOjCl4oaTeqYR3r6/wtbyPk +86AwDQYJKoZIhvcNAQEMBQADggIBAJaAcgkGfpzMkwQWu6A6jZJOtxEaCnFxEM0E +rX+lRVAQZk5KQaID2RFPeje5S+LGjzJmdSX7684/AykmjbgWHfYfM25I5uj4V7Ib +ed87hwriZLoAymzvftAj63iP/2SbNDefNWWipAA9EiOWWF3KY4fGoweITedpdopT +zfFP7ELyk+OZpDc8h7hi2/DsHzc/N19DzFGdtfCXwreFamgLRB7lUe6TzktuhsHS +DCRZNhqfLJGP4xjblJUK7ZGqDpncllPjYYPGFrojutzdfhrGe0K22VoF3Jpf1d+4 +2kd92jjbrDnVHmtsKheMYc2xbXIBw8MgAGJoFjHVdqqGuw6qnsb58Nn4DSEC5MUo +FlkRudlpcyqSeLiSV5sI8jrlL5WwWLdrIBRtFO8KvH7YVdiI2i/6GaX7i+B/OfVy +K4XELKzvGUWSTLNhB9xNH27SgRNcmvMSZ4PPmz+Ln52kuaiWA3rF7iDeM9ovnhp6 +dB7h7sxaOgTdsxoEqBRjrLdHEoOabPXm6RUVkRqEGQ6UROcSjiVbgGcZ3GOTEAtl +Lor6CZpO2oYofaphNdgOpygau1LgePhsumywbrmHXumZNTfxPWQrqaA0k89jL9WB +365jJ6UeTo3cKXhZ+PmhIIynJkBugnLNeLLIjzwec+fBH7/PzqUqm9tEZDKgu39c +JRNItX+S +-----END CERTIFICATE----- + +# Issuer: CN=SecureSign Root CA15 O=Cybertrust Japan Co., Ltd. +# Subject: CN=SecureSign Root CA15 O=Cybertrust Japan Co., Ltd. +# Label: "SecureSign Root CA15" +# Serial: 126083514594751269499665114766174399806381178503 +# MD5 Fingerprint: 13:30:fc:c4:62:a6:a9:de:b5:c1:68:af:b5:d2:31:47 +# SHA1 Fingerprint: cb:ba:83:c8:c1:5a:5d:f1:f9:73:6f:ca:d7:ef:28:13:06:4a:07:7d +# SHA256 Fingerprint: e7:78:f0:f0:95:fe:84:37:29:cd:1a:00:82:17:9e:53:14:a9:c2:91:44:28:05:e1:fb:1d:8f:b6:b8:88:6c:3a +-----BEGIN CERTIFICATE----- +MIICIzCCAamgAwIBAgIUFhXHw9hJp75pDIqI7fBw+d23PocwCgYIKoZIzj0EAwMw +UTELMAkGA1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28uLCBM +dGQuMR0wGwYDVQQDExRTZWN1cmVTaWduIFJvb3QgQ0ExNTAeFw0yMDA0MDgwODMy +NTZaFw00NTA0MDgwODMyNTZaMFExCzAJBgNVBAYTAkpQMSMwIQYDVQQKExpDeWJl +cnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2VjdXJlU2lnbiBSb290 +IENBMTUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQLUHSNZDKZmbPSYAi4Io5GdCx4 +wCtELW1fHcmuS1Iggz24FG1Th2CeX2yF2wYUleDHKP+dX+Sq8bOLbe1PL0vJSpSR +ZHX+AezB2Ot6lHhWGENfa4HL9rzatAy2KZMIaY+jQjBAMA8GA1UdEwEB/wQFMAMB +Af8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTrQciu/NWeUUj1vYv0hyCTQSvT +9DAKBggqhkjOPQQDAwNoADBlAjEA2S6Jfl5OpBEHvVnCB96rMjhTKkZEBhd6zlHp +4P9mLQlO4E/0BdGF9jVg3PVys0Z9AjBEmEYagoUeYWmJSwdLZrWeqrqgHkHZAXQ6 +bkU6iYAZezKYVWOr62Nuk22rGwlgMU4= +-----END CERTIFICATE----- diff --git a/lib/importlib_resources/__init__.py b/lib/importlib_resources/__init__.py index 0d029abd..723c9f9e 100644 --- a/lib/importlib_resources/__init__.py +++ b/lib/importlib_resources/__init__.py @@ -1,4 +1,11 @@ -"""Read resources contained within a package.""" +""" +Read resources contained within a package. + +This codebase is shared between importlib.resources in the stdlib +and importlib_resources in PyPI. See +https://github.com/python/importlib_metadata/wiki/Development-Methodology +for more detail. +""" from ._common import ( as_file, @@ -7,7 +14,7 @@ from ._common import ( Anchor, ) -from .functional import ( +from ._functional import ( contents, is_resource, open_binary, diff --git a/lib/importlib_resources/_common.py b/lib/importlib_resources/_common.py index 8df6b39e..f065d493 100644 --- a/lib/importlib_resources/_common.py +++ b/lib/importlib_resources/_common.py @@ -66,10 +66,10 @@ def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]: # zipimport.zipimporter does not support weak references, resulting in a # TypeError. That seems terrible. spec = package.__spec__ - reader = getattr(spec.loader, 'get_resource_reader', None) # type: ignore + reader = getattr(spec.loader, 'get_resource_reader', None) # type: ignore[union-attr] if reader is None: return None - return reader(spec.name) # type: ignore + return reader(spec.name) # type: ignore[union-attr] @functools.singledispatch @@ -93,12 +93,13 @@ def _infer_caller(): """ def is_this_file(frame_info): - return frame_info.filename == __file__ + return frame_info.filename == stack[0].filename def is_wrapper(frame_info): return frame_info.function == 'wrapper' - not_this_file = itertools.filterfalse(is_this_file, inspect.stack()) + stack = inspect.stack() + not_this_file = itertools.filterfalse(is_this_file, stack) # also exclude 'wrapper' due to singledispatch in the call stack callers = itertools.filterfalse(is_wrapper, not_this_file) return next(callers).frame @@ -182,7 +183,7 @@ def _(path): @contextlib.contextmanager def _temp_path(dir: tempfile.TemporaryDirectory): """ - Wrap tempfile.TemporyDirectory to return a pathlib object. + Wrap tempfile.TemporaryDirectory to return a pathlib object. """ with dir as result: yield pathlib.Path(result) diff --git a/lib/importlib_resources/functional.py b/lib/importlib_resources/_functional.py similarity index 100% rename from lib/importlib_resources/functional.py rename to lib/importlib_resources/_functional.py diff --git a/lib/importlib_resources/compat/py39.py b/lib/importlib_resources/compat/py39.py index ab87b9dc..ed5abd5e 100644 --- a/lib/importlib_resources/compat/py39.py +++ b/lib/importlib_resources/compat/py39.py @@ -5,6 +5,6 @@ __all__ = ['ZipPath'] if sys.version_info >= (3, 10): - from zipfile import Path as ZipPath # type: ignore + from zipfile import Path as ZipPath else: - from zipp import Path as ZipPath # type: ignore + from zipp import Path as ZipPath diff --git a/lib/importlib_resources/readers.py b/lib/importlib_resources/readers.py index 4a80a774..4f761c64 100644 --- a/lib/importlib_resources/readers.py +++ b/lib/importlib_resources/readers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import collections import contextlib import itertools @@ -5,6 +7,7 @@ import pathlib import operator import re import warnings +from collections.abc import Iterator from . import abc @@ -34,8 +37,10 @@ class FileReader(abc.TraversableResources): class ZipReader(abc.TraversableResources): def __init__(self, loader, module): - _, _, name = module.rpartition('.') - self.prefix = loader.prefix.replace('\\', '/') + name + '/' + self.prefix = loader.prefix.replace('\\', '/') + if loader.is_package(module): + _, _, name = module.rpartition('.') + self.prefix += name + '/' self.archive = loader.archive def open_resource(self, resource): @@ -133,27 +138,31 @@ class NamespaceReader(abc.TraversableResources): def __init__(self, namespace_path): if 'NamespacePath' not in str(namespace_path): raise ValueError('Invalid path') - self.path = MultiplexedPath(*map(self._resolve, namespace_path)) + self.path = MultiplexedPath(*filter(bool, map(self._resolve, namespace_path))) @classmethod - def _resolve(cls, path_str) -> abc.Traversable: + def _resolve(cls, path_str) -> abc.Traversable | None: r""" Given an item from a namespace path, resolve it to a Traversable. path_str might be a directory on the filesystem or a path to a zipfile plus the path within the zipfile, e.g. ``/foo/bar`` or ``/foo/baz.zip/inner_dir`` or ``foo\baz.zip\inner_dir\sub``. + + path_str might also be a sentinel used by editable packages to + trigger other behaviors (see python/importlib_resources#311). + In that case, return None. """ - (dir,) = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir()) - return dir + dirs = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir()) + return next(dirs, None) @classmethod - def _candidate_paths(cls, path_str): + def _candidate_paths(cls, path_str: str) -> Iterator[abc.Traversable]: yield pathlib.Path(path_str) yield from cls._resolve_zip_path(path_str) @staticmethod - def _resolve_zip_path(path_str): + def _resolve_zip_path(path_str: str): for match in reversed(list(re.finditer(r'[\\/]', path_str))): with contextlib.suppress( FileNotFoundError, diff --git a/lib/importlib_resources/simple.py b/lib/importlib_resources/simple.py index 96f117fe..2e75299b 100644 --- a/lib/importlib_resources/simple.py +++ b/lib/importlib_resources/simple.py @@ -77,7 +77,7 @@ class ResourceHandle(Traversable): def __init__(self, parent: ResourceContainer, name: str): self.parent = parent - self.name = name # type: ignore + self.name = name # type: ignore[misc] def is_file(self): return True diff --git a/lib/importlib_resources/tests/_path.py b/lib/importlib_resources/tests/_path.py index 1f97c961..b144628c 100644 --- a/lib/importlib_resources/tests/_path.py +++ b/lib/importlib_resources/tests/_path.py @@ -2,15 +2,44 @@ import pathlib import functools from typing import Dict, Union +from typing import runtime_checkable +from typing import Protocol #### -# from jaraco.path 3.4.1 - -FilesSpec = Dict[str, Union[str, bytes, 'FilesSpec']] # type: ignore +# from jaraco.path 3.7.1 -def build(spec: FilesSpec, prefix=pathlib.Path()): +class Symlink(str): + """ + A string indicating the target of a symlink. + """ + + +FilesSpec = Dict[str, Union[str, bytes, Symlink, 'FilesSpec']] + + +@runtime_checkable +class TreeMaker(Protocol): + def __truediv__(self, *args, **kwargs): ... # pragma: no cover + + def mkdir(self, **kwargs): ... # pragma: no cover + + def write_text(self, content, **kwargs): ... # pragma: no cover + + def write_bytes(self, content): ... # pragma: no cover + + def symlink_to(self, target): ... # pragma: no cover + + +def _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker: + return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj) # type: ignore[return-value] + + +def build( + spec: FilesSpec, + prefix: Union[str, TreeMaker] = pathlib.Path(), # type: ignore[assignment] +): """ Build a set of files/directories, as described by the spec. @@ -25,21 +54,25 @@ def build(spec: FilesSpec, prefix=pathlib.Path()): ... "__init__.py": "", ... }, ... "baz.py": "# Some code", - ... } + ... "bar.py": Symlink("baz.py"), + ... }, + ... "bing": Symlink("foo"), ... } >>> target = getfixture('tmp_path') >>> build(spec, target) >>> target.joinpath('foo/baz.py').read_text(encoding='utf-8') '# Some code' + >>> target.joinpath('bing/bar.py').read_text(encoding='utf-8') + '# Some code' """ for name, contents in spec.items(): - create(contents, pathlib.Path(prefix) / name) + create(contents, _ensure_tree_maker(prefix) / name) @functools.singledispatch def create(content: Union[str, bytes, FilesSpec], path): path.mkdir(exist_ok=True) - build(content, prefix=path) # type: ignore + build(content, prefix=path) # type: ignore[arg-type] @create.register @@ -52,5 +85,10 @@ def _(content: str, path): path.write_text(content, encoding='utf-8') +@create.register +def _(content: Symlink, path): + path.symlink_to(content) + + # end from jaraco.path #### diff --git a/lib/importlib_resources/tests/compat/py39.py b/lib/importlib_resources/tests/compat/py39.py index e158eb85..e01d276b 100644 --- a/lib/importlib_resources/tests/compat/py39.py +++ b/lib/importlib_resources/tests/compat/py39.py @@ -8,3 +8,6 @@ import_helper = try_import('import_helper') or from_test_support( 'modules_setup', 'modules_cleanup', 'DirsOnSysPath' ) os_helper = try_import('os_helper') or from_test_support('temp_dir') +warnings_helper = try_import('warnings_helper') or from_test_support( + 'ignore_warnings', 'check_warnings' +) diff --git a/lib/importlib_resources/tests/data01/__init__.py b/lib/importlib_resources/tests/data01/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/importlib_resources/tests/data01/binary.file b/lib/importlib_resources/tests/data01/binary.file deleted file mode 100644 index eaf36c1d..00000000 Binary files a/lib/importlib_resources/tests/data01/binary.file and /dev/null differ diff --git a/lib/importlib_resources/tests/data01/subdirectory/__init__.py b/lib/importlib_resources/tests/data01/subdirectory/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/importlib_resources/tests/data01/subdirectory/binary.file b/lib/importlib_resources/tests/data01/subdirectory/binary.file deleted file mode 100644 index 5bd8bb89..00000000 --- a/lib/importlib_resources/tests/data01/subdirectory/binary.file +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/lib/importlib_resources/tests/data01/utf-16.file b/lib/importlib_resources/tests/data01/utf-16.file deleted file mode 100644 index 2cb77229..00000000 Binary files a/lib/importlib_resources/tests/data01/utf-16.file and /dev/null differ diff --git a/lib/importlib_resources/tests/data01/utf-8.file b/lib/importlib_resources/tests/data01/utf-8.file deleted file mode 100644 index 1c0132ad..00000000 --- a/lib/importlib_resources/tests/data01/utf-8.file +++ /dev/null @@ -1 +0,0 @@ -Hello, UTF-8 world! diff --git a/lib/importlib_resources/tests/data02/__init__.py b/lib/importlib_resources/tests/data02/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/importlib_resources/tests/data02/one/__init__.py b/lib/importlib_resources/tests/data02/one/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/importlib_resources/tests/data02/one/resource1.txt b/lib/importlib_resources/tests/data02/one/resource1.txt deleted file mode 100644 index 61a813e4..00000000 --- a/lib/importlib_resources/tests/data02/one/resource1.txt +++ /dev/null @@ -1 +0,0 @@ -one resource diff --git a/lib/importlib_resources/tests/data02/subdirectory/subsubdir/resource.txt b/lib/importlib_resources/tests/data02/subdirectory/subsubdir/resource.txt deleted file mode 100644 index 48f587a2..00000000 --- a/lib/importlib_resources/tests/data02/subdirectory/subsubdir/resource.txt +++ /dev/null @@ -1 +0,0 @@ -a resource \ No newline at end of file diff --git a/lib/importlib_resources/tests/data02/two/__init__.py b/lib/importlib_resources/tests/data02/two/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/importlib_resources/tests/data02/two/resource2.txt b/lib/importlib_resources/tests/data02/two/resource2.txt deleted file mode 100644 index a80ce46e..00000000 --- a/lib/importlib_resources/tests/data02/two/resource2.txt +++ /dev/null @@ -1 +0,0 @@ -two resource diff --git a/lib/importlib_resources/tests/namespacedata01/binary.file b/lib/importlib_resources/tests/namespacedata01/binary.file deleted file mode 100644 index eaf36c1d..00000000 Binary files a/lib/importlib_resources/tests/namespacedata01/binary.file and /dev/null differ diff --git a/lib/importlib_resources/tests/namespacedata01/subdirectory/binary.file b/lib/importlib_resources/tests/namespacedata01/subdirectory/binary.file deleted file mode 100644 index 100f5064..00000000 --- a/lib/importlib_resources/tests/namespacedata01/subdirectory/binary.file +++ /dev/null @@ -1 +0,0 @@ -  \ No newline at end of file diff --git a/lib/importlib_resources/tests/namespacedata01/utf-16.file b/lib/importlib_resources/tests/namespacedata01/utf-16.file deleted file mode 100644 index 2cb77229..00000000 Binary files a/lib/importlib_resources/tests/namespacedata01/utf-16.file and /dev/null differ diff --git a/lib/importlib_resources/tests/namespacedata01/utf-8.file b/lib/importlib_resources/tests/namespacedata01/utf-8.file deleted file mode 100644 index 1c0132ad..00000000 --- a/lib/importlib_resources/tests/namespacedata01/utf-8.file +++ /dev/null @@ -1 +0,0 @@ -Hello, UTF-8 world! diff --git a/lib/importlib_resources/tests/test_contents.py b/lib/importlib_resources/tests/test_contents.py index 7dc3b0a6..741a7407 100644 --- a/lib/importlib_resources/tests/test_contents.py +++ b/lib/importlib_resources/tests/test_contents.py @@ -1,7 +1,6 @@ import unittest import importlib_resources as resources -from . import data01 from . import util @@ -19,16 +18,17 @@ class ContentsTests: assert self.expected <= contents -class ContentsDiskTests(ContentsTests, unittest.TestCase): - def setUp(self): - self.data = data01 +class ContentsDiskTests(ContentsTests, util.DiskSetup, unittest.TestCase): + pass class ContentsZipTests(ContentsTests, util.ZipSetup, unittest.TestCase): pass -class ContentsNamespaceTests(ContentsTests, unittest.TestCase): +class ContentsNamespaceTests(ContentsTests, util.DiskSetup, unittest.TestCase): + MODULE = 'namespacedata01' + expected = { # no __init__ because of namespace design 'binary.file', @@ -36,8 +36,3 @@ class ContentsNamespaceTests(ContentsTests, unittest.TestCase): 'utf-16.file', 'utf-8.file', } - - def setUp(self): - from . import namespacedata01 - - self.data = namespacedata01 diff --git a/lib/importlib_resources/tests/test_files.py b/lib/importlib_resources/tests/test_files.py index 3e86ec64..f1fe2337 100644 --- a/lib/importlib_resources/tests/test_files.py +++ b/lib/importlib_resources/tests/test_files.py @@ -1,3 +1,7 @@ +import os +import pathlib +import py_compile +import shutil import textwrap import unittest import warnings @@ -6,11 +10,8 @@ import contextlib import importlib_resources as resources from ..abc import Traversable -from . import data01 from . import util -from . import _path -from .compat.py39 import os_helper -from .compat.py312 import import_helper +from .compat.py39 import os_helper, import_helper @contextlib.contextmanager @@ -48,70 +49,146 @@ class FilesTests: resources.files(package=self.data) -class OpenDiskTests(FilesTests, unittest.TestCase): - def setUp(self): - self.data = data01 +class OpenDiskTests(FilesTests, util.DiskSetup, unittest.TestCase): + pass class OpenZipTests(FilesTests, util.ZipSetup, unittest.TestCase): pass -class OpenNamespaceTests(FilesTests, unittest.TestCase): - def setUp(self): - from . import namespacedata01 +class OpenNamespaceTests(FilesTests, util.DiskSetup, unittest.TestCase): + MODULE = 'namespacedata01' - self.data = namespacedata01 + def test_non_paths_in_dunder_path(self): + """ + Non-path items in a namespace package's ``__path__`` are ignored. + + As reported in python/importlib_resources#311, some tools + like Setuptools, when creating editable packages, will inject + non-paths into a namespace package's ``__path__``, a + sentinel like + ``__editable__.sample_namespace-1.0.finder.__path_hook__`` + to cause the ``PathEntryFinder`` to be called when searching + for packages. In that case, resources should still be loadable. + """ + import namespacedata01 + + namespacedata01.__path__.append( + '__editable__.sample_namespace-1.0.finder.__path_hook__' + ) + + resources.files(namespacedata01) class OpenNamespaceZipTests(FilesTests, util.ZipSetup, unittest.TestCase): ZIP_MODULE = 'namespacedata01' -class SiteDir: - def setUp(self): - self.fixtures = contextlib.ExitStack() - self.addCleanup(self.fixtures.close) - self.site_dir = self.fixtures.enter_context(os_helper.temp_dir()) - self.fixtures.enter_context(import_helper.DirsOnSysPath(self.site_dir)) - self.fixtures.enter_context(import_helper.isolated_modules()) +class DirectSpec: + """ + Override behavior of ModuleSetup to write a full spec directly. + """ + + MODULE = 'unused' + + def load_fixture(self, name): + self.tree_on_path(self.spec) -class ModulesFilesTests(SiteDir, unittest.TestCase): +class ModulesFiles: + spec = { + 'mod.py': '', + 'res.txt': 'resources are the best', + } + def test_module_resources(self): """ A module can have resources found adjacent to the module. """ - spec = { - 'mod.py': '', - 'res.txt': 'resources are the best', - } - _path.build(spec, self.site_dir) - import mod + import mod # type: ignore[import-not-found] actual = resources.files(mod).joinpath('res.txt').read_text(encoding='utf-8') - assert actual == spec['res.txt'] + assert actual == self.spec['res.txt'] -class ImplicitContextFilesTests(SiteDir, unittest.TestCase): - def test_implicit_files(self): +class ModuleFilesDiskTests(DirectSpec, util.DiskSetup, ModulesFiles, unittest.TestCase): + pass + + +class ModuleFilesZipTests(DirectSpec, util.ZipSetup, ModulesFiles, unittest.TestCase): + pass + + +class ImplicitContextFiles: + set_val = textwrap.dedent( + f""" + import {resources.__name__} as res + val = res.files().joinpath('res.txt').read_text(encoding='utf-8') + """ + ) + spec = { + 'somepkg': { + '__init__.py': set_val, + 'submod.py': set_val, + 'res.txt': 'resources are the best', + }, + 'frozenpkg': { + '__init__.py': set_val.replace(resources.__name__, 'c_resources'), + 'res.txt': 'resources are the best', + }, + } + + def test_implicit_files_package(self): """ Without any parameter, files() will infer the location as the caller. """ - spec = { - 'somepkg': { - '__init__.py': textwrap.dedent( - """ - import importlib_resources as res - val = res.files().joinpath('res.txt').read_text(encoding='utf-8') - """ - ), - 'res.txt': 'resources are the best', - }, - } - _path.build(spec, self.site_dir) assert importlib.import_module('somepkg').val == 'resources are the best' + def test_implicit_files_submodule(self): + """ + Without any parameter, files() will infer the location as the caller. + """ + assert importlib.import_module('somepkg.submod').val == 'resources are the best' + + def _compile_importlib(self): + """ + Make a compiled-only copy of the importlib resources package. + """ + bin_site = self.fixtures.enter_context(os_helper.temp_dir()) + c_resources = pathlib.Path(bin_site, 'c_resources') + sources = pathlib.Path(resources.__file__).parent + shutil.copytree(sources, c_resources, ignore=lambda *_: ['__pycache__']) + + for dirpath, _, filenames in os.walk(c_resources): + for filename in filenames: + source_path = pathlib.Path(dirpath) / filename + cfile = source_path.with_suffix('.pyc') + py_compile.compile(source_path, cfile) + pathlib.Path.unlink(source_path) + self.fixtures.enter_context(import_helper.DirsOnSysPath(bin_site)) + + def test_implicit_files_with_compiled_importlib(self): + """ + Caller detection works for compiled-only resources module. + + python/cpython#123085 + """ + self._compile_importlib() + assert importlib.import_module('frozenpkg').val == 'resources are the best' + + +class ImplicitContextFilesDiskTests( + DirectSpec, util.DiskSetup, ImplicitContextFiles, unittest.TestCase +): + pass + + +class ImplicitContextFilesZipTests( + DirectSpec, util.ZipSetup, ImplicitContextFiles, unittest.TestCase +): + pass + if __name__ == '__main__': unittest.main() diff --git a/lib/importlib_resources/tests/test_functional.py b/lib/importlib_resources/tests/test_functional.py index 69706cf7..1851edfb 100644 --- a/lib/importlib_resources/tests/test_functional.py +++ b/lib/importlib_resources/tests/test_functional.py @@ -1,31 +1,38 @@ import unittest import os -import contextlib +import importlib -try: - from test.support.warnings_helper import ignore_warnings, check_warnings -except ImportError: - # older Python versions - from test.support import ignore_warnings, check_warnings +from .compat.py39 import warnings_helper import importlib_resources as resources +from . import util + # Since the functional API forwards to Traversable, we only test # filesystem resources here -- not zip files, namespace packages etc. # We do test for two kinds of Anchor, though. class StringAnchorMixin: - anchor01 = 'importlib_resources.tests.data01' - anchor02 = 'importlib_resources.tests.data02' + anchor01 = 'data01' + anchor02 = 'data02' class ModuleAnchorMixin: - from . import data01 as anchor01 - from . import data02 as anchor02 + @property + def anchor01(self): + return importlib.import_module('data01') + + @property + def anchor02(self): + return importlib.import_module('data02') -class FunctionalAPIBase: +class FunctionalAPIBase(util.DiskSetup): + def setUp(self): + super().setUp() + self.load_fixture('data02') + def _gen_resourcetxt_path_parts(self): """Yield various names of a text file in anchor02, each in a subTest""" for path_parts in ( @@ -36,6 +43,12 @@ class FunctionalAPIBase: with self.subTest(path_parts=path_parts): yield path_parts + def assertEndsWith(self, string, suffix): + """Assert that `string` ends with `suffix`. + + Used to ignore an architecture-specific UTF-16 byte-order mark.""" + self.assertEqual(string[-len(suffix) :], suffix) + def test_read_text(self): self.assertEqual( resources.read_text(self.anchor01, 'utf-8.file'), @@ -76,13 +89,13 @@ class FunctionalAPIBase: ), '\x00\x01\x02\x03', ) - self.assertEqual( + self.assertEndsWith( # ignore the BOM resources.read_text( self.anchor01, 'utf-16.file', errors='backslashreplace', ), - 'Hello, UTF-16 world!\n'.encode('utf-16').decode( + 'Hello, UTF-16 world!\n'.encode('utf-16-le').decode( errors='backslashreplace', ), ) @@ -128,9 +141,9 @@ class FunctionalAPIBase: 'utf-16.file', errors='backslashreplace', ) as f: - self.assertEqual( + self.assertEndsWith( # ignore the BOM f.read(), - 'Hello, UTF-16 world!\n'.encode('utf-16').decode( + 'Hello, UTF-16 world!\n'.encode('utf-16-le').decode( errors='backslashreplace', ), ) @@ -163,32 +176,32 @@ class FunctionalAPIBase: self.assertTrue(is_resource(self.anchor02, *path_parts)) def test_contents(self): - with check_warnings((".*contents.*", DeprecationWarning)): + with warnings_helper.check_warnings((".*contents.*", DeprecationWarning)): c = resources.contents(self.anchor01) self.assertGreaterEqual( set(c), {'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'}, ) - with contextlib.ExitStack() as cm: - cm.enter_context(self.assertRaises(OSError)) - cm.enter_context(check_warnings((".*contents.*", DeprecationWarning))) - + with self.assertRaises(OSError), warnings_helper.check_warnings(( + ".*contents.*", + DeprecationWarning, + )): list(resources.contents(self.anchor01, 'utf-8.file')) for path_parts in self._gen_resourcetxt_path_parts(): - with contextlib.ExitStack() as cm: - cm.enter_context(self.assertRaises(OSError)) - cm.enter_context(check_warnings((".*contents.*", DeprecationWarning))) - + with self.assertRaises(OSError), warnings_helper.check_warnings(( + ".*contents.*", + DeprecationWarning, + )): list(resources.contents(self.anchor01, *path_parts)) - with check_warnings((".*contents.*", DeprecationWarning)): + with warnings_helper.check_warnings((".*contents.*", DeprecationWarning)): c = resources.contents(self.anchor01, 'subdirectory') self.assertGreaterEqual( set(c), {'binary.file'}, ) - @ignore_warnings(category=DeprecationWarning) + @warnings_helper.ignore_warnings(category=DeprecationWarning) def test_common_errors(self): for func in ( resources.read_text, @@ -227,16 +240,16 @@ class FunctionalAPIBase: class FunctionalAPITest_StringAnchor( - unittest.TestCase, - FunctionalAPIBase, StringAnchorMixin, + FunctionalAPIBase, + unittest.TestCase, ): pass class FunctionalAPITest_ModuleAnchor( - unittest.TestCase, - FunctionalAPIBase, ModuleAnchorMixin, + FunctionalAPIBase, + unittest.TestCase, ): pass diff --git a/lib/importlib_resources/tests/test_open.py b/lib/importlib_resources/tests/test_open.py index 44f1018a..c40bb8c6 100644 --- a/lib/importlib_resources/tests/test_open.py +++ b/lib/importlib_resources/tests/test_open.py @@ -1,7 +1,6 @@ import unittest import importlib_resources as resources -from . import data01 from . import util @@ -65,16 +64,12 @@ class OpenTests: target.open(encoding='utf-8') -class OpenDiskTests(OpenTests, unittest.TestCase): - def setUp(self): - self.data = data01 +class OpenDiskTests(OpenTests, util.DiskSetup, unittest.TestCase): + pass -class OpenDiskNamespaceTests(OpenTests, unittest.TestCase): - def setUp(self): - from . import namespacedata01 - - self.data = namespacedata01 +class OpenDiskNamespaceTests(OpenTests, util.DiskSetup, unittest.TestCase): + MODULE = 'namespacedata01' class OpenZipTests(OpenTests, util.ZipSetup, unittest.TestCase): @@ -82,7 +77,7 @@ class OpenZipTests(OpenTests, util.ZipSetup, unittest.TestCase): class OpenNamespaceZipTests(OpenTests, util.ZipSetup, unittest.TestCase): - ZIP_MODULE = 'namespacedata01' + MODULE = 'namespacedata01' if __name__ == '__main__': diff --git a/lib/importlib_resources/tests/test_path.py b/lib/importlib_resources/tests/test_path.py index c3e1cbb4..1e30f2bc 100644 --- a/lib/importlib_resources/tests/test_path.py +++ b/lib/importlib_resources/tests/test_path.py @@ -3,7 +3,6 @@ import pathlib import unittest import importlib_resources as resources -from . import data01 from . import util @@ -25,9 +24,7 @@ class PathTests: self.assertEqual('Hello, UTF-8 world!\n', path.read_text(encoding='utf-8')) -class PathDiskTests(PathTests, unittest.TestCase): - data = data01 - +class PathDiskTests(PathTests, util.DiskSetup, unittest.TestCase): def test_natural_path(self): """ Guarantee the internal implementation detail that diff --git a/lib/importlib_resources/tests/test_read.py b/lib/importlib_resources/tests/test_read.py index 97d90128..6780a2d1 100644 --- a/lib/importlib_resources/tests/test_read.py +++ b/lib/importlib_resources/tests/test_read.py @@ -1,7 +1,6 @@ import unittest import importlib_resources as resources -from . import data01 from . import util from importlib import import_module @@ -52,8 +51,8 @@ class ReadTests: ) -class ReadDiskTests(ReadTests, unittest.TestCase): - data = data01 +class ReadDiskTests(ReadTests, util.DiskSetup, unittest.TestCase): + pass class ReadZipTests(ReadTests, util.ZipSetup, unittest.TestCase): @@ -69,15 +68,12 @@ class ReadZipTests(ReadTests, util.ZipSetup, unittest.TestCase): self.assertEqual(result, bytes(range(4, 8))) -class ReadNamespaceTests(ReadTests, unittest.TestCase): - def setUp(self): - from . import namespacedata01 - - self.data = namespacedata01 +class ReadNamespaceTests(ReadTests, util.DiskSetup, unittest.TestCase): + MODULE = 'namespacedata01' class ReadNamespaceZipTests(ReadTests, util.ZipSetup, unittest.TestCase): - ZIP_MODULE = 'namespacedata01' + MODULE = 'namespacedata01' def test_read_submodule_resource(self): submodule = import_module('namespacedata01.subdirectory') diff --git a/lib/importlib_resources/tests/test_reader.py b/lib/importlib_resources/tests/test_reader.py index 95c2fc85..0a77eb40 100644 --- a/lib/importlib_resources/tests/test_reader.py +++ b/lib/importlib_resources/tests/test_reader.py @@ -1,16 +1,21 @@ import os.path -import sys import pathlib import unittest from importlib import import_module from importlib_resources.readers import MultiplexedPath, NamespaceReader +from . import util -class MultiplexedPathTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.folder = pathlib.Path(__file__).parent / 'namespacedata01' + +class MultiplexedPathTest(util.DiskSetup, unittest.TestCase): + MODULE = 'namespacedata01' + + def setUp(self): + super().setUp() + self.folder = pathlib.Path(self.data.__path__[0]) + self.data01 = pathlib.Path(self.load_fixture('data01').__file__).parent + self.data02 = pathlib.Path(self.load_fixture('data02').__file__).parent def test_init_no_paths(self): with self.assertRaises(FileNotFoundError): @@ -31,9 +36,8 @@ class MultiplexedPathTest(unittest.TestCase): ) def test_iterdir_duplicate(self): - data01 = pathlib.Path(__file__).parent.joinpath('data01') contents = { - path.name for path in MultiplexedPath(self.folder, data01).iterdir() + path.name for path in MultiplexedPath(self.folder, self.data01).iterdir() } for remove in ('__pycache__', '__init__.pyc'): try: @@ -61,9 +65,8 @@ class MultiplexedPathTest(unittest.TestCase): path.open() def test_join_path(self): - data01 = pathlib.Path(__file__).parent.joinpath('data01') - prefix = str(data01.parent) - path = MultiplexedPath(self.folder, data01) + prefix = str(self.folder.parent) + path = MultiplexedPath(self.folder, self.data01) self.assertEqual( str(path.joinpath('binary.file'))[len(prefix) + 1 :], os.path.join('namespacedata01', 'binary.file'), @@ -83,10 +86,8 @@ class MultiplexedPathTest(unittest.TestCase): assert not path.joinpath('imaginary/foo.py').exists() def test_join_path_common_subdir(self): - data01 = pathlib.Path(__file__).parent.joinpath('data01') - data02 = pathlib.Path(__file__).parent.joinpath('data02') - prefix = str(data01.parent) - path = MultiplexedPath(data01, data02) + prefix = str(self.data02.parent) + path = MultiplexedPath(self.data01, self.data02) self.assertIsInstance(path.joinpath('subdirectory'), MultiplexedPath) self.assertEqual( str(path.joinpath('subdirectory', 'subsubdir'))[len(prefix) + 1 :], @@ -106,16 +107,8 @@ class MultiplexedPathTest(unittest.TestCase): ) -class NamespaceReaderTest(unittest.TestCase): - site_dir = str(pathlib.Path(__file__).parent) - - @classmethod - def setUpClass(cls): - sys.path.append(cls.site_dir) - - @classmethod - def tearDownClass(cls): - sys.path.remove(cls.site_dir) +class NamespaceReaderTest(util.DiskSetup, unittest.TestCase): + MODULE = 'namespacedata01' def test_init_error(self): with self.assertRaises(ValueError): @@ -125,7 +118,7 @@ class NamespaceReaderTest(unittest.TestCase): namespacedata01 = import_module('namespacedata01') reader = NamespaceReader(namespacedata01.__spec__.submodule_search_locations) - root = os.path.abspath(os.path.join(__file__, '..', 'namespacedata01')) + root = self.data.__path__[0] self.assertEqual( reader.resource_path('binary.file'), os.path.join(root, 'binary.file') ) @@ -134,9 +127,8 @@ class NamespaceReaderTest(unittest.TestCase): ) def test_files(self): - namespacedata01 = import_module('namespacedata01') - reader = NamespaceReader(namespacedata01.__spec__.submodule_search_locations) - root = os.path.abspath(os.path.join(__file__, '..', 'namespacedata01')) + reader = NamespaceReader(self.data.__spec__.submodule_search_locations) + root = self.data.__path__[0] self.assertIsInstance(reader.files(), MultiplexedPath) self.assertEqual(repr(reader.files()), f"MultiplexedPath('{root}')") diff --git a/lib/importlib_resources/tests/test_resource.py b/lib/importlib_resources/tests/test_resource.py index dc2a108c..a0da6a35 100644 --- a/lib/importlib_resources/tests/test_resource.py +++ b/lib/importlib_resources/tests/test_resource.py @@ -1,9 +1,6 @@ -import sys import unittest import importlib_resources as resources -import pathlib -from . import data01 from . import util from importlib import import_module @@ -25,9 +22,8 @@ class ResourceTests: self.assertTrue(target.is_dir()) -class ResourceDiskTests(ResourceTests, unittest.TestCase): - def setUp(self): - self.data = data01 +class ResourceDiskTests(ResourceTests, util.DiskSetup, unittest.TestCase): + pass class ResourceZipTests(ResourceTests, util.ZipSetup, unittest.TestCase): @@ -38,33 +34,39 @@ def names(traversable): return {item.name for item in traversable.iterdir()} -class ResourceLoaderTests(unittest.TestCase): +class ResourceLoaderTests(util.DiskSetup, unittest.TestCase): def test_resource_contents(self): package = util.create_package( - file=data01, path=data01.__file__, contents=['A', 'B', 'C'] + file=self.data, path=self.data.__file__, contents=['A', 'B', 'C'] ) self.assertEqual(names(resources.files(package)), {'A', 'B', 'C'}) def test_is_file(self): package = util.create_package( - file=data01, path=data01.__file__, contents=['A', 'B', 'C', 'D/E', 'D/F'] + file=self.data, + path=self.data.__file__, + contents=['A', 'B', 'C', 'D/E', 'D/F'], ) self.assertTrue(resources.files(package).joinpath('B').is_file()) def test_is_dir(self): package = util.create_package( - file=data01, path=data01.__file__, contents=['A', 'B', 'C', 'D/E', 'D/F'] + file=self.data, + path=self.data.__file__, + contents=['A', 'B', 'C', 'D/E', 'D/F'], ) self.assertTrue(resources.files(package).joinpath('D').is_dir()) def test_resource_missing(self): package = util.create_package( - file=data01, path=data01.__file__, contents=['A', 'B', 'C', 'D/E', 'D/F'] + file=self.data, + path=self.data.__file__, + contents=['A', 'B', 'C', 'D/E', 'D/F'], ) self.assertFalse(resources.files(package).joinpath('Z').is_file()) -class ResourceCornerCaseTests(unittest.TestCase): +class ResourceCornerCaseTests(util.DiskSetup, unittest.TestCase): def test_package_has_no_reader_fallback(self): """ Test odd ball packages which: @@ -73,7 +75,7 @@ class ResourceCornerCaseTests(unittest.TestCase): # 3. Are not in a zip file """ module = util.create_package( - file=data01, path=data01.__file__, contents=['A', 'B', 'C'] + file=self.data, path=self.data.__file__, contents=['A', 'B', 'C'] ) # Give the module a dummy loader. module.__loader__ = object() @@ -84,9 +86,7 @@ class ResourceCornerCaseTests(unittest.TestCase): self.assertFalse(resources.files(module).joinpath('A').is_file()) -class ResourceFromZipsTest01(util.ZipSetupBase, unittest.TestCase): - ZIP_MODULE = 'data01' - +class ResourceFromZipsTest01(util.ZipSetup, unittest.TestCase): def test_is_submodule_resource(self): submodule = import_module('data01.subdirectory') self.assertTrue(resources.files(submodule).joinpath('binary.file').is_file()) @@ -117,8 +117,8 @@ class ResourceFromZipsTest01(util.ZipSetupBase, unittest.TestCase): assert not data.parent.exists() -class ResourceFromZipsTest02(util.ZipSetupBase, unittest.TestCase): - ZIP_MODULE = 'data02' +class ResourceFromZipsTest02(util.ZipSetup, unittest.TestCase): + MODULE = 'data02' def test_unrelated_contents(self): """ @@ -135,7 +135,7 @@ class ResourceFromZipsTest02(util.ZipSetupBase, unittest.TestCase): ) -class DeletingZipsTest(util.ZipSetupBase, unittest.TestCase): +class DeletingZipsTest(util.ZipSetup, unittest.TestCase): """Having accessed resources in a zip file should not keep an open reference to the zip. """ @@ -217,24 +217,20 @@ class ResourceFromNamespaceTests: self.assertEqual(contents, {'binary.file'}) -class ResourceFromNamespaceDiskTests(ResourceFromNamespaceTests, unittest.TestCase): - site_dir = str(pathlib.Path(__file__).parent) - - @classmethod - def setUpClass(cls): - sys.path.append(cls.site_dir) - - @classmethod - def tearDownClass(cls): - sys.path.remove(cls.site_dir) - - -class ResourceFromNamespaceZipTests( - util.ZipSetupBase, +class ResourceFromNamespaceDiskTests( + util.DiskSetup, ResourceFromNamespaceTests, unittest.TestCase, ): - ZIP_MODULE = 'namespacedata01' + MODULE = 'namespacedata01' + + +class ResourceFromNamespaceZipTests( + util.ZipSetup, + ResourceFromNamespaceTests, + unittest.TestCase, +): + MODULE = 'namespacedata01' if __name__ == '__main__': diff --git a/lib/importlib_resources/tests/util.py b/lib/importlib_resources/tests/util.py index fb827d2f..a4eafac3 100644 --- a/lib/importlib_resources/tests/util.py +++ b/lib/importlib_resources/tests/util.py @@ -6,10 +6,10 @@ import types import pathlib import contextlib -from . import data01 from ..abc import ResourceReader from .compat.py39 import import_helper, os_helper from . import zip as zip_ +from . import _path from importlib.machinery import ModuleSpec @@ -68,7 +68,7 @@ def create_package(file=None, path=None, is_package=True, contents=()): ) -class CommonTests(metaclass=abc.ABCMeta): +class CommonTestsBase(metaclass=abc.ABCMeta): """ Tests shared by test_open, test_path, and test_read. """ @@ -84,34 +84,34 @@ class CommonTests(metaclass=abc.ABCMeta): """ Passing in the package name should succeed. """ - self.execute(data01.__name__, 'utf-8.file') + self.execute(self.data.__name__, 'utf-8.file') def test_package_object(self): """ Passing in the package itself should succeed. """ - self.execute(data01, 'utf-8.file') + self.execute(self.data, 'utf-8.file') def test_string_path(self): """ Passing in a string for the path should succeed. """ path = 'utf-8.file' - self.execute(data01, path) + self.execute(self.data, path) def test_pathlib_path(self): """ Passing in a pathlib.PurePath object for the path should succeed. """ path = pathlib.PurePath('utf-8.file') - self.execute(data01, path) + self.execute(self.data, path) def test_importing_module_as_side_effect(self): """ The anchor package can already be imported. """ - del sys.modules[data01.__name__] - self.execute(data01.__name__, 'utf-8.file') + del sys.modules[self.data.__name__] + self.execute(self.data.__name__, 'utf-8.file') def test_missing_path(self): """ @@ -141,24 +141,66 @@ class CommonTests(metaclass=abc.ABCMeta): self.execute(package, 'utf-8.file') -class ZipSetupBase: - ZIP_MODULE = 'data01' +fixtures = dict( + data01={ + '__init__.py': '', + 'binary.file': bytes(range(4)), + 'utf-16.file': 'Hello, UTF-16 world!\n'.encode('utf-16'), + 'utf-8.file': 'Hello, UTF-8 world!\n'.encode('utf-8'), + 'subdirectory': { + '__init__.py': '', + 'binary.file': bytes(range(4, 8)), + }, + }, + data02={ + '__init__.py': '', + 'one': {'__init__.py': '', 'resource1.txt': 'one resource'}, + 'two': {'__init__.py': '', 'resource2.txt': 'two resource'}, + 'subdirectory': {'subsubdir': {'resource.txt': 'a resource'}}, + }, + namespacedata01={ + 'binary.file': bytes(range(4)), + 'utf-16.file': 'Hello, UTF-16 world!\n'.encode('utf-16'), + 'utf-8.file': 'Hello, UTF-8 world!\n'.encode('utf-8'), + 'subdirectory': { + 'binary.file': bytes(range(12, 16)), + }, + }, +) + +class ModuleSetup: def setUp(self): self.fixtures = contextlib.ExitStack() self.addCleanup(self.fixtures.close) self.fixtures.enter_context(import_helper.isolated_modules()) + self.data = self.load_fixture(self.MODULE) + def load_fixture(self, module): + self.tree_on_path({module: fixtures[module]}) + return importlib.import_module(module) + + +class ZipSetup(ModuleSetup): + MODULE = 'data01' + + def tree_on_path(self, spec): temp_dir = self.fixtures.enter_context(os_helper.temp_dir()) modules = pathlib.Path(temp_dir) / 'zipped modules.zip' - src_path = pathlib.Path(__file__).parent.joinpath(self.ZIP_MODULE) self.fixtures.enter_context( - import_helper.DirsOnSysPath(str(zip_.make_zip_file(src_path, modules))) + import_helper.DirsOnSysPath(str(zip_.make_zip_file(spec, modules))) ) - self.data = importlib.import_module(self.ZIP_MODULE) + +class DiskSetup(ModuleSetup): + MODULE = 'data01' + + def tree_on_path(self, spec): + temp_dir = self.fixtures.enter_context(os_helper.temp_dir()) + _path.build(spec, pathlib.Path(temp_dir)) + self.fixtures.enter_context(import_helper.DirsOnSysPath(temp_dir)) -class ZipSetup(ZipSetupBase): +class CommonTests(DiskSetup, CommonTestsBase): pass diff --git a/lib/importlib_resources/tests/zip.py b/lib/importlib_resources/tests/zip.py index 962195a9..51ee5648 100644 --- a/lib/importlib_resources/tests/zip.py +++ b/lib/importlib_resources/tests/zip.py @@ -2,31 +2,25 @@ Generate zip test data files. """ -import contextlib -import os -import pathlib import zipfile import zipp -def make_zip_file(src, dst): +def make_zip_file(tree, dst): """ - Zip the files in src into a new zipfile at dst. + Zip the files in tree into a new zipfile at dst. """ with zipfile.ZipFile(dst, 'w') as zf: - for src_path, rel in walk(src): - dst_name = src.name / pathlib.PurePosixPath(rel.as_posix()) - zf.write(src_path, dst_name) + for name, contents in walk(tree): + zf.writestr(name, contents) zipp.CompleteDirs.inject(zf) return dst -def walk(datapath): - for dirpath, dirnames, filenames in os.walk(datapath): - with contextlib.suppress(ValueError): - dirnames.remove('__pycache__') - for filename in filenames: - res = pathlib.Path(dirpath) / filename - rel = res.relative_to(datapath) - yield res, rel +def walk(tree, prefix=''): + for name, contents in tree.items(): + if isinstance(contents, dict): + yield from walk(contents, prefix=f'{prefix}{name}/') + else: + yield f'{prefix}{name}', contents diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index 8f84f3be..686073a3 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -193,6 +193,7 @@ class Artist( similar (List<:class:`~plexapi.media.Similar`>): List of similar objects. styles (List<:class:`~plexapi.media.Style`>): List of style objects. theme (str): URL to theme resource (/library/metadata//theme/). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. """ TAG = 'Directory' TYPE = 'artist' @@ -213,6 +214,7 @@ class Artist( self.similar = self.findItems(data, media.Similar) self.styles = self.findItems(data, media.Style) self.theme = data.attrib.get('theme') + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) def __iter__(self): for album in self.albums(): @@ -281,6 +283,21 @@ class Artist( filepaths += track.download(_savepath, keep_original_name, **kwargs) return filepaths + def popularTracks(self): + """ Returns a list of :class:`~plexapi.audio.Track` popular tracks by the artist. """ + filters = { + 'album.subformat!': 'Compilation,Live', + 'artist.id': self.ratingKey, + 'group': 'title', + 'ratingCount>>': 0, + } + return self.section().search( + libtype='track', + filters=filters, + sort='ratingCount:desc', + limit=100 + ) + def station(self): """ Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """ key = f'{self.key}?includeStations=1' @@ -325,6 +342,7 @@ class Album( studio (str): Studio that released the album. styles (List<:class:`~plexapi.media.Style`>): List of style objects. subformats (List<:class:`~plexapi.media.Subformat`>): List of subformat objects. + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. viewedLeafCount (int): Number of items marked as played in the album view. year (int): Year the album was released. """ @@ -354,6 +372,7 @@ class Album( self.studio = data.attrib.get('studio') self.styles = self.findItems(data, media.Style) self.subformats = self.findItems(data, media.Subformat) + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 26b103b9..a7fa82ee 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -3,7 +3,7 @@ import re from typing import TYPE_CHECKING, Generic, Iterable, List, Optional, TypeVar, Union import weakref from functools import cached_property -from urllib.parse import urlencode +from urllib.parse import parse_qsl, urlencode, urlparse from xml.etree import ElementTree from xml.etree.ElementTree import Element @@ -391,10 +391,9 @@ class PlexObject: Parameters: key (string, optional): Override the key to reload. - **kwargs (dict): A dictionary of XML include parameters to exclude or override. - All parameters are included by default with the option to override each parameter - or disable each parameter individually by setting it to False or 0. + **kwargs (dict): A dictionary of XML include parameters to include/exclude or override. See :class:`~plexapi.base.PlexPartialObject` for all the available include parameters. + Set parameter to True to include and False to exclude. Example: @@ -402,20 +401,28 @@ class PlexObject: from plexapi.server import PlexServer plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx') - movie = plex.library.section('Movies').get('Cars') - # Partial reload of the movie without the `checkFiles` parameter. - # Excluding `checkFiles` will prevent the Plex server from reading the - # file to check if the file still exists and is accessible. - # The movie object will remain as a partial object. - movie.reload(checkFiles=False) + # Search results are partial objects. + movie = plex.library.section('Movies').get('Cars') movie.isPartialObject() # Returns True - # Full reload of the movie with all include parameters. + # Partial reload of the movie without a default include parameter. + # The movie object will remain as a partial object. + movie.reload(includeMarkers=False) + movie.isPartialObject() # Returns True + + # Full reload of the movie with all default include parameters. # The movie object will be a full object. movie.reload() movie.isFullObject() # Returns True + # Full reload of the movie with all default and extra include parameter. + # Including `checkFiles` will tell the Plex server to check if the file + # still exists and is accessible. + # The movie object will be a full object. + movie.reload(checkFiles=True) + movie.isFullObject() # Returns True + """ return self._reload(key=key, **kwargs) @@ -505,25 +512,25 @@ class PlexPartialObject(PlexObject): automatically and update itself. """ _INCLUDES = { - 'checkFiles': 1, - 'includeAllConcerts': 1, + 'checkFiles': 0, + 'includeAllConcerts': 0, 'includeBandwidths': 1, 'includeChapters': 1, - 'includeChildren': 1, - 'includeConcerts': 1, - 'includeExternalMedia': 1, - 'includeExtras': 1, + 'includeChildren': 0, + 'includeConcerts': 0, + 'includeExternalMedia': 0, + 'includeExtras': 0, 'includeFields': 'thumbBlurHash,artBlurHash', 'includeGeolocation': 1, 'includeLoudnessRamps': 1, 'includeMarkers': 1, - 'includeOnDeck': 1, - 'includePopularLeaves': 1, - 'includePreferences': 1, - 'includeRelated': 1, - 'includeRelatedCount': 1, - 'includeReviews': 1, - 'includeStations': 1, + 'includeOnDeck': 0, + 'includePopularLeaves': 0, + 'includePreferences': 0, + 'includeRelated': 0, + 'includeRelatedCount': 0, + 'includeReviews': 0, + 'includeStations': 0, } _EXCLUDES = { 'excludeElements': ( @@ -592,7 +599,11 @@ class PlexPartialObject(PlexObject): search result for a movie often only contain a portion of the attributes a full object (main url) for that movie would contain. """ - return not self.key or (self._details_key or self.key) == self._initpath + parsed_key = urlparse(self._details_key or self.key) + parsed_initpath = urlparse(self._initpath) + query_key = set(parse_qsl(parsed_key.query)) + query_init = set(parse_qsl(parsed_initpath.query)) + return not self.key or (parsed_key.path == parsed_initpath.path and query_key <= query_init) def isPartialObject(self): """ Returns True if this is not a full object. """ diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index 76513e79..3d89e3dc 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -197,7 +197,7 @@ class PlexClient(PlexObject): raise NotFound(message) else: raise BadRequest(message) - data = response.text.encode('utf8') + data = utils.cleanXMLString(response.text).encode('utf8') return ElementTree.fromstring(data) if data.strip() else None def sendCommand(self, command, proxy=None, **params): diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index d71ddf2f..1c3ba3f7 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -60,6 +60,7 @@ class Collection( title (str): Name of the collection. titleSort (str): Title to use when sorting (defaults to title). type (str): 'collection' + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. updatedAt (datetime): Datetime the collection was updated. userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars). """ @@ -102,6 +103,7 @@ class Collection( self.title = data.attrib.get('title') self.titleSort = data.attrib.get('titleSort', self.title) self.type = data.attrib.get('type') + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.userRating = utils.cast(float, data.attrib.get('userRating')) self._items = None # cache for self.items diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index b0fe7e7d..130555ad 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -4,6 +4,6 @@ # Library version MAJOR_VERSION = 4 MINOR_VERSION = 15 -PATCH_VERSION = 15 +PATCH_VERSION = 16 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index f1bf5375..6913b829 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -2823,7 +2823,8 @@ class FilteringType(PlexObject): additionalFields.extend([ ('duration', 'integer', 'Duration'), ('viewOffset', 'integer', 'View Offset'), - ('label', 'tag', 'Label') + ('label', 'tag', 'Label'), + ('ratingCount', 'integer', 'Rating Count'), ]) elif self.type == 'collection': additionalFields.extend([ diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 56126dcb..2f76d722 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -106,12 +106,16 @@ class MediaPart(PlexObject): Attributes: TAG (str): 'Part' accessible (bool): True if the file is accessible. + Requires reloading the media with ``checkFiles=True``. + Refer to :func:`~plexapi.base.PlexObject.reload`. audioProfile (str): The audio profile of the file. container (str): The container type of the file (ex: avi). decision (str): Unknown. deepAnalysisVersion (int): The Plex deep analysis version for the file. duration (int): The duration of the file in milliseconds. exists (bool): True if the file exists. + Requires reloading the media with ``checkFiles=True``. + Refer to :func:`~plexapi.base.PlexObject.reload`. file (str): The path to this file on disk (ex: /media/Movies/Cars (2006)/Cars (2006).mkv) has64bitOffsets (bool): True if the file has 64 bit offsets. hasThumbnail (bool): True if the file (track) has an embedded thumbnail. @@ -999,6 +1003,28 @@ class Review(PlexObject): self.text = data.attrib.get('text') +@utils.registerPlexObject +class UltraBlurColors(PlexObject): + """ Represents a single UltraBlurColors media tag. + + Attributes: + TAG (str): 'UltraBlurColors' + bottomLeft (str): The bottom left hex color. + bottomRight (str): The bottom right hex color. + topLeft (str): The top left hex color. + topRight (str): The top right hex color. + """ + TAG = 'UltraBlurColors' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.bottomLeft = data.attrib.get('bottomLeft') + self.bottomRight = data.attrib.get('bottomRight') + self.topLeft = data.attrib.get('topLeft') + self.topRight = data.attrib.get('topRight') + + class BaseResource(PlexObject): """ Base class for all Art, Poster, and Theme objects. diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index 8571ba63..bdf4607e 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -14,8 +14,8 @@ class AdvancedSettingsMixin: def preferences(self): """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ - data = self._server.query(self._details_key) - return self.findItems(data, settings.Preferences, rtag='Preferences') + key = f'{self.key}?includePreferences=1' + return self.fetchItems(key, cls=settings.Preferences, rtag='Preferences') def preference(self, pref): """ Returns a :class:`~plexapi.settings.Preferences` object for the specified pref. @@ -240,8 +240,7 @@ class UnmatchMatchMixin: params['agent'] = utils.getAgentIdentifier(self.section(), agent) key = key + '?' + urlencode(params) - data = self._server.query(key, method=self._server._session.get) - return self.findItems(data, initpath=key) + return self.fetchItems(key, cls=media.SearchResult) def fixMatch(self, searchResult=None, auto=False, agent=None): """ Use match result to update show metadata. @@ -278,8 +277,8 @@ class ExtrasMixin: def extras(self): """ Returns a list of :class:`~plexapi.video.Extra` objects. """ from plexapi.video import Extra - data = self._server.query(self._details_key) - return self.findItems(data, Extra, rtag='Extras') + key = f'{self.key}/extras' + return self.fetchItems(key, cls=Extra) class HubsMixin: @@ -289,8 +288,7 @@ class HubsMixin: """ Returns a list of :class:`~plexapi.library.Hub` objects. """ from plexapi.library import Hub key = f'{self.key}/related' - data = self._server.query(key) - return self.findItems(data, Hub) + return self.fetchItems(key, cls=Hub) class PlayedUnplayedMixin: diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index bc40583e..24e32e6b 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -250,7 +250,7 @@ class MyPlexAccount(PlexObject): return response.json() elif 'text/plain' in response.headers.get('Content-Type', ''): return response.text.strip() - data = response.text.encode('utf8') + data = utils.cleanXMLString(response.text).encode('utf8') return ElementTree.fromstring(data) if data.strip() else None def ping(self): diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index f39a423f..8cd110d8 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -768,7 +768,7 @@ class PlexServer(PlexObject): raise NotFound(message) else: raise BadRequest(message) - data = response.text.encode('utf8') + data = utils.cleanXMLString(response.text).encode('utf8') return ElementTree.fromstring(data) if data.strip() else None def search(self, query, mediatype=None, limit=None, sectionId=None): diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index bb128532..549afc5b 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -6,6 +6,7 @@ import logging import os import re import string +import sys import time import unicodedata import warnings @@ -673,3 +674,45 @@ def openOrRead(file): def sha1hash(guid): """ Return the SHA1 hash of a guid. """ return sha1(guid.encode('utf-8')).hexdigest() + + +# https://stackoverflow.com/a/64570125 +_illegal_XML_characters = [ + (0x00, 0x08), + (0x0B, 0x0C), + (0x0E, 0x1F), + (0x7F, 0x84), + (0x86, 0x9F), + (0xFDD0, 0xFDDF), + (0xFFFE, 0xFFFF), +] +if sys.maxunicode >= 0x10000: # not narrow build + _illegal_XML_characters.extend( + [ + (0x1FFFE, 0x1FFFF), + (0x2FFFE, 0x2FFFF), + (0x3FFFE, 0x3FFFF), + (0x4FFFE, 0x4FFFF), + (0x5FFFE, 0x5FFFF), + (0x6FFFE, 0x6FFFF), + (0x7FFFE, 0x7FFFF), + (0x8FFFE, 0x8FFFF), + (0x9FFFE, 0x9FFFF), + (0xAFFFE, 0xAFFFF), + (0xBFFFE, 0xBFFFF), + (0xCFFFE, 0xCFFFF), + (0xDFFFE, 0xDFFFF), + (0xEFFFE, 0xEFFFF), + (0xFFFFE, 0xFFFFF), + (0x10FFFE, 0x10FFFF), + ] + ) +_illegal_XML_ranges = [ + fr'{chr(low)}-{chr(high)}' + for (low, high) in _illegal_XML_characters +] +_illegal_XML_re = re.compile(fr'[{"".join(_illegal_XML_ranges)}]') + + +def cleanXMLString(s): + return _illegal_XML_re.sub('', s) diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 609f57f6..15755415 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -375,6 +375,7 @@ class Movie( studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment). tagline (str): Movie tag line (Back 2 Work; Who says men can't change?). theme (str): URL to theme resource (/library/metadata//theme/). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. useOriginalTitle (int): Setting that indicates if the original title is used for the movie (-1 = Library default, 0 = No, 1 = Yes). viewOffset (int): View offset in milliseconds. @@ -420,6 +421,7 @@ class Movie( self.studio = data.attrib.get('studio') self.tagline = data.attrib.get('tagline') self.theme = data.attrib.get('theme') + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.writers = self.findItems(data, media.Writer) @@ -456,8 +458,8 @@ class Movie( def reviews(self): """ Returns a list of :class:`~plexapi.media.Review` objects. """ - data = self._server.query(self._details_key) - return self.findItems(data, media.Review, rtag='Video') + key = f'{self.key}?includeReviews=1' + return self.fetchItems(key, cls=media.Review, rtag='Video') def editions(self): """ Returns a list of :class:`~plexapi.video.Movie` objects @@ -543,6 +545,7 @@ class Show( (-1 = Account default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). tagline (str): Show tag line. theme (str): URL to theme resource (/library/metadata//theme/). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. useOriginalTitle (int): Setting that indicates if the original title is used for the show (-1 = Library default, 0 = No, 1 = Yes). viewedLeafCount (int): Number of items marked as played in the show view. @@ -592,6 +595,7 @@ class Show( self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) self.tagline = data.attrib.get('tagline') self.theme = data.attrib.get('theme') + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) @@ -614,8 +618,8 @@ class Show( """ Returns show's On Deck :class:`~plexapi.video.Video` object or `None`. If show is unwatched, return will likely be the first episode. """ - data = self._server.query(self._details_key) - return next(iter(self.findItems(data, rtag='OnDeck')), None) + key = f'{self.key}?includeOnDeck=1' + return next(iter(self.fetchItems(key, cls=Episode, rtag='OnDeck')), None) def season(self, title=None, season=None): """ Returns the season with the specified title or number. @@ -735,6 +739,7 @@ class Season( subtitleLanguage (str): Setting that indicates the preferred subtitle language. subtitleMode (int): Setting that indicates the auto-select subtitle mode. (-1 = Series default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. viewedLeafCount (int): Number of items marked as played in the season view. year (int): Year the season was released. """ @@ -766,6 +771,7 @@ class Season( self.ratings = self.findItems(data, media.Rating) self.subtitleLanguage = data.attrib.get('subtitleLanguage', '') self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) @@ -796,8 +802,8 @@ class Season( """ Returns season's On Deck :class:`~plexapi.video.Video` object or `None`. Will only return a match if the show's On Deck episode is in this season. """ - data = self._server.query(self._details_key) - return next(iter(self.findItems(data, rtag='OnDeck')), None) + key = f'{self.key}?includeOnDeck=1' + return next(iter(self.fetchItems(key, cls=Episode, rtag='OnDeck')), None) def episode(self, title=None, episode=None): """ Returns the episode with the given title or number. @@ -914,6 +920,7 @@ class Episode( skipParent (bool): True if the show's seasons are set to hidden. sourceURI (str): Remote server URI (server:///com.plexapp.plugins.library) (remote playlist item only). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. viewOffset (int): View offset in milliseconds. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. year (int): Year the episode was released. @@ -958,6 +965,7 @@ class Episode( self.roles = self.findItems(data, media.Role) self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) self.sourceURI = data.attrib.get('source') # remote playlist item + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.writers = self.findItems(data, media.Writer) self.year = utils.cast(int, data.attrib.get('year')) diff --git a/lib/pyparsing/__init__.py b/lib/pyparsing/__init__.py index 79d8153c..a440cfbe 100644 --- a/lib/pyparsing/__init__.py +++ b/lib/pyparsing/__init__.py @@ -120,8 +120,8 @@ class version_info(NamedTuple): return f"{__name__}.{type(self).__name__}({', '.join('{}={!r}'.format(*nv) for nv in zip(self._fields, self))})" -__version_info__ = version_info(3, 1, 2, "final", 1) -__version_time__ = "06 Mar 2024 07:08 UTC" +__version_info__ = version_info(3, 1, 4, "final", 1) +__version_time__ = "25 Aug 2024 14:40 UTC" __version__ = __version_info__.__version__ __versionTime__ = __version_time__ __author__ = "Paul McGuire " @@ -143,7 +143,7 @@ from .common import ( _builtin_exprs as common_builtin_exprs, ) -# define backward compat synonyms +# Compatibility synonyms if "pyparsing_unicode" not in globals(): pyparsing_unicode = unicode # type: ignore[misc] if "pyparsing_common" not in globals(): diff --git a/lib/pyparsing/actions.py b/lib/pyparsing/actions.py index ce51b395..1d2dce99 100644 --- a/lib/pyparsing/actions.py +++ b/lib/pyparsing/actions.py @@ -196,7 +196,7 @@ def with_class(classname, namespace=""): return with_attribute(**{classattr: classname}) -# pre-PEP8 compatibility symbols +# Compatibility synonyms # fmt: off replaceWith = replaced_by_pep8("replaceWith", replace_with) removeQuotes = replaced_by_pep8("removeQuotes", remove_quotes) diff --git a/lib/pyparsing/common.py b/lib/pyparsing/common.py index 74faa460..649aad00 100644 --- a/lib/pyparsing/common.py +++ b/lib/pyparsing/common.py @@ -418,20 +418,15 @@ class pyparsing_common: # fmt: on # pre-PEP8 compatibility names - convertToInteger = convert_to_integer - """Deprecated - use :class:`convert_to_integer`""" - convertToFloat = convert_to_float - """Deprecated - use :class:`convert_to_float`""" - convertToDate = convert_to_date - """Deprecated - use :class:`convert_to_date`""" - convertToDatetime = convert_to_datetime - """Deprecated - use :class:`convert_to_datetime`""" - stripHTMLTags = strip_html_tags - """Deprecated - use :class:`strip_html_tags`""" - upcaseTokens = upcase_tokens - """Deprecated - use :class:`upcase_tokens`""" - downcaseTokens = downcase_tokens - """Deprecated - use :class:`downcase_tokens`""" + # fmt: off + convertToInteger = staticmethod(replaced_by_pep8("convertToInteger", convert_to_integer)) + convertToFloat = staticmethod(replaced_by_pep8("convertToFloat", convert_to_float)) + convertToDate = staticmethod(replaced_by_pep8("convertToDate", convert_to_date)) + convertToDatetime = staticmethod(replaced_by_pep8("convertToDatetime", convert_to_datetime)) + stripHTMLTags = staticmethod(replaced_by_pep8("stripHTMLTags", strip_html_tags)) + upcaseTokens = staticmethod(replaced_by_pep8("upcaseTokens", upcase_tokens)) + downcaseTokens = staticmethod(replaced_by_pep8("downcaseTokens", downcase_tokens)) + # fmt: on _builtin_exprs = [ diff --git a/lib/pyparsing/core.py b/lib/pyparsing/core.py index b19d1221..cbe73c98 100644 --- a/lib/pyparsing/core.py +++ b/lib/pyparsing/core.py @@ -53,6 +53,11 @@ from .unicode import pyparsing_unicode _MAX_INT = sys.maxsize str_type: Tuple[type, ...] = (str, bytes) +if sys.version_info >= (3, 7): + _RePattern = re.Pattern +else: + _RePattern = typing.Pattern + # # Copyright (c) 2003-2022 Paul T. McGuire # @@ -218,19 +223,11 @@ if _should_enable_warnings( # build list of single arg builtins, that can be used as parse actions +# fmt: off _single_arg_builtins = { - sum, - len, - sorted, - reversed, - list, - tuple, - set, - any, - all, - min, - max, + sum, len, sorted, reversed, list, tuple, set, any, all, min, max } +# fmt: on _generatorType = types.GeneratorType ParseImplReturnType = Tuple[int, Any] @@ -255,13 +252,13 @@ DebugSuccessAction = Callable[ DebugExceptionAction = Callable[[str, int, "ParserElement", Exception, bool], None] -alphas = string.ascii_uppercase + string.ascii_lowercase -identchars = pyparsing_unicode.Latin1.identchars -identbodychars = pyparsing_unicode.Latin1.identbodychars -nums = "0123456789" -hexnums = nums + "ABCDEFabcdef" -alphanums = alphas + nums -printables = "".join([c for c in string.printable if c not in string.whitespace]) +alphas: str = string.ascii_uppercase + string.ascii_lowercase +identchars: str = pyparsing_unicode.Latin1.identchars +identbodychars: str = pyparsing_unicode.Latin1.identbodychars +nums: str = "0123456789" +hexnums: str = nums + "ABCDEFabcdef" +alphanums: str = alphas + nums +printables: str = "".join([c for c in string.printable if c not in string.whitespace]) _trim_arity_call_line: traceback.StackSummary = None # type: ignore[assignment] @@ -280,7 +277,7 @@ def _trim_arity(func, max_limit=3): # user's parse action 'func', so that we don't incur call penalty at parse time # fmt: off - LINE_DIFF = 7 + LINE_DIFF = 9 # IF ANY CODE CHANGES, EVEN JUST COMMENTS OR BLANK LINES, BETWEEN THE NEXT LINE AND # THE CALL TO FUNC INSIDE WRAPPER, LINE_DIFF MUST BE MODIFIED!!!! _trim_arity_call_line = (_trim_arity_call_line or traceback.extract_stack(limit=2)[-1]) @@ -288,6 +285,8 @@ def _trim_arity(func, max_limit=3): def wrapper(*args): nonlocal found_arity, limit + if found_arity: + return func(*args[limit:]) while 1: try: ret = func(*args[limit:]) @@ -476,7 +475,7 @@ class ParserElement(ABC): self.streamlined = False # optimize exception handling for subclasses that don't advance parse index self.mayIndexError = True - self.errmsg = "" + self.errmsg: Union[str, None] = "" # mark results names as modal (report only last) or cumulative (list all) self.modalResults = True # custom debug actions @@ -582,15 +581,15 @@ class ParserElement(ABC): listAllMatches = listAllMatches or list_all_matches return self._setResultsName(name, listAllMatches) - def _setResultsName(self, name, listAllMatches=False): + def _setResultsName(self, name, list_all_matches=False) -> "ParserElement": if name is None: return self newself = self.copy() if name.endswith("*"): name = name[:-1] - listAllMatches = True + list_all_matches = True newself.resultsName = name - newself.modalResults = not listAllMatches + newself.modalResults = not list_all_matches return newself def set_break(self, break_flag: bool = True) -> "ParserElement": @@ -602,12 +601,12 @@ class ParserElement(ABC): if break_flag: _parseMethod = self._parse - def breaker(instring, loc, doActions=True, callPreParse=True): + def breaker(instring, loc, do_actions=True, callPreParse=True): import pdb # this call to pdb.set_trace() is intentional, not a checkin error pdb.set_trace() - return _parseMethod(instring, loc, doActions, callPreParse) + return _parseMethod(instring, loc, do_actions, callPreParse) breaker._originalParseMethod = _parseMethod # type: ignore [attr-defined] self._parse = breaker # type: ignore [assignment] @@ -615,7 +614,7 @@ class ParserElement(ABC): self._parse = self._parse._originalParseMethod # type: ignore [attr-defined, assignment] return self - def set_parse_action(self, *fns: ParseAction, **kwargs) -> "ParserElement": + def set_parse_action(self, *fns: ParseAction, **kwargs: Any) -> "ParserElement": """ Define one or more actions to perform when successfully matching parse element definition. @@ -691,19 +690,19 @@ class ParserElement(ABC): ''') """ if list(fns) == [None]: - self.parseAction = [] + self.parseAction.clear() return self if not all(callable(fn) for fn in fns): raise TypeError("parse actions must be callable") - self.parseAction = [_trim_arity(fn) for fn in fns] + self.parseAction[:] = [_trim_arity(fn) for fn in fns] self.callDuringTry = kwargs.get( "call_during_try", kwargs.get("callDuringTry", False) ) return self - def add_parse_action(self, *fns: ParseAction, **kwargs) -> "ParserElement": + def add_parse_action(self, *fns: ParseAction, **kwargs: Any) -> "ParserElement": """ Add one or more parse actions to expression's list of parse actions. See :class:`set_parse_action`. @@ -715,7 +714,7 @@ class ParserElement(ABC): ) return self - def add_condition(self, *fns: ParseCondition, **kwargs) -> "ParserElement": + def add_condition(self, *fns: ParseCondition, **kwargs: Any) -> "ParserElement": """Add a boolean predicate function to expression's list of parse actions. See :class:`set_parse_action` for function call signatures. Unlike ``set_parse_action``, functions passed to ``add_condition`` need to return boolean success/fail of the condition. @@ -801,7 +800,7 @@ class ParserElement(ABC): return loc - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: return loc, [] def postParse(self, instring, loc, tokenlist): @@ -809,10 +808,10 @@ class ParserElement(ABC): # @profile def _parseNoCache( - self, instring, loc, doActions=True, callPreParse=True + self, instring, loc, do_actions=True, callPreParse=True ) -> Tuple[int, ParseResults]: TRY, MATCH, FAIL = 0, 1, 2 - debugging = self.debug # and doActions) + debugging = self.debug # and do_actions) len_instring = len(instring) if debugging or self.failAction: @@ -827,11 +826,11 @@ class ParserElement(ABC): self.debugActions.debug_try(instring, tokens_start, self, False) if self.mayIndexError or pre_loc >= len_instring: try: - loc, tokens = self.parseImpl(instring, pre_loc, doActions) + loc, tokens = self.parseImpl(instring, pre_loc, do_actions) except IndexError: raise ParseException(instring, len_instring, self.errmsg, self) else: - loc, tokens = self.parseImpl(instring, pre_loc, doActions) + loc, tokens = self.parseImpl(instring, pre_loc, do_actions) except Exception as err: # print("Exception raised:", err) if self.debugActions.debug_fail: @@ -849,18 +848,18 @@ class ParserElement(ABC): tokens_start = pre_loc if self.mayIndexError or pre_loc >= len_instring: try: - loc, tokens = self.parseImpl(instring, pre_loc, doActions) + loc, tokens = self.parseImpl(instring, pre_loc, do_actions) except IndexError: raise ParseException(instring, len_instring, self.errmsg, self) else: - loc, tokens = self.parseImpl(instring, pre_loc, doActions) + loc, tokens = self.parseImpl(instring, pre_loc, do_actions) tokens = self.postParse(instring, loc, tokens) ret_tokens = ParseResults( tokens, self.resultsName, asList=self.saveAsList, modal=self.modalResults ) - if self.parseAction and (doActions or self.callDuringTry): + if self.parseAction and (do_actions or self.callDuringTry): if debugging: try: for fn in self.parseAction: @@ -919,7 +918,7 @@ class ParserElement(ABC): do_actions: bool = False, ) -> int: try: - return self._parse(instring, loc, doActions=do_actions)[0] + return self._parse(instring, loc, do_actions=do_actions)[0] except ParseFatalException: if raise_fatal: raise @@ -960,18 +959,18 @@ class ParserElement(ABC): # this method gets repeatedly called during backtracking with the same arguments - # we can cache these arguments and save ourselves the trouble of re-parsing the contained expression def _parseCache( - self, instring, loc, doActions=True, callPreParse=True + self, instring, loc, do_actions=True, callPreParse=True ) -> Tuple[int, ParseResults]: HIT, MISS = 0, 1 TRY, MATCH, FAIL = 0, 1, 2 - lookup = (self, instring, loc, callPreParse, doActions) + lookup = (self, instring, loc, callPreParse, do_actions) with ParserElement.packrat_cache_lock: cache = ParserElement.packrat_cache value = cache.get(lookup) if value is cache.not_in_cache: ParserElement.packrat_cache_stats[MISS] += 1 try: - value = self._parseNoCache(instring, loc, doActions, callPreParse) + value = self._parseNoCache(instring, loc, do_actions, callPreParse) except ParseBaseException as pe: # cache a copy of the exception, without the traceback cache.set(lookup, pe.__class__(*pe.args)) @@ -1190,7 +1189,7 @@ class ParserElement(ABC): loc, tokens = self._parse(instring, 0) if parseAll: loc = self.preParse(instring, loc) - se = Empty() + StringEnd() + se = Empty() + StringEnd().set_debug(False) se._parse(instring, loc) except ParseBaseException as exc: if ParserElement.verbose_stacktrace: @@ -1887,9 +1886,14 @@ class ParserElement(ABC): Child classes must define this method, which defines how the ``default_name`` is set. """ - def set_name(self, name: str) -> "ParserElement": + def set_name(self, name: typing.Optional[str]) -> "ParserElement": """ - Define name for this expression, makes debugging and exception messages clearer. + Define name for this expression, makes debugging and exception messages clearer. If + `__diag__.enable_debug_on_named_expressions` is set to True, setting a name will also + enable debug for this expression. + + If `name` is None, clears any custom name for this expression, and clears the + debug flag is it was enabled via `__diag__.enable_debug_on_named_expressions`. Example:: @@ -1900,9 +1904,11 @@ class ParserElement(ABC): integer.parse_string("ABC") # -> Exception: Expected integer (at char 0), (line:1, col:1) """ self.customName = name - self.errmsg = f"Expected {self.name}" + self.errmsg = f"Expected {str(self)}" + if __diag__.enable_debug_on_named_expressions: - self.set_debug() + self.set_debug(name is not None) + return self @property @@ -1910,6 +1916,10 @@ class ParserElement(ABC): # This will use a user-defined name if available, but otherwise defaults back to the auto-generated name return self.customName if self.customName is not None else self.default_name + @name.setter + def name(self, new_name) -> None: + self.set_name(new_name) + def __str__(self) -> str: return self.name @@ -2014,7 +2024,9 @@ class ParserElement(ABC): full_dump: bool = True, print_results: bool = True, failure_tests: bool = False, - post_parse: typing.Optional[Callable[[str, ParseResults], str]] = None, + post_parse: typing.Optional[ + Callable[[str, ParseResults], typing.Optional[str]] + ] = None, file: typing.Optional[TextIO] = None, with_line_numbers: bool = False, *, @@ -2022,7 +2034,9 @@ class ParserElement(ABC): fullDump: bool = True, printResults: bool = True, failureTests: bool = False, - postParse: typing.Optional[Callable[[str, ParseResults], str]] = None, + postParse: typing.Optional[ + Callable[[str, ParseResults], typing.Optional[str]] + ] = None, ) -> Tuple[bool, List[Tuple[str, Union[ParseResults, Exception]]]]: """ Execute the parse expression on a series of test strings, showing each @@ -2159,7 +2173,7 @@ class ParserElement(ABC): f"{nlstr}{nlstr.join(comments) if comments else ''}", pyparsing_test.with_line_numbers(t) if with_line_numbers else t, ] - comments = [] + comments.clear() try: # convert newline marks to actual newlines, and strip leading BOM if present t = NL.transform_string(t.lstrip(BOM)) @@ -2264,10 +2278,15 @@ class ParserElement(ABC): # Compatibility synonyms # fmt: off - inlineLiteralsUsing = replaced_by_pep8("inlineLiteralsUsing", inline_literals_using) - setDefaultWhitespaceChars = replaced_by_pep8( + inlineLiteralsUsing = staticmethod(replaced_by_pep8("inlineLiteralsUsing", inline_literals_using)) + setDefaultWhitespaceChars = staticmethod(replaced_by_pep8( "setDefaultWhitespaceChars", set_default_whitespace_chars - ) + )) + disableMemoization = staticmethod(replaced_by_pep8("disableMemoization", disable_memoization)) + enableLeftRecursion = staticmethod(replaced_by_pep8("enableLeftRecursion", enable_left_recursion)) + enablePackrat = staticmethod(replaced_by_pep8("enablePackrat", enable_packrat)) + resetCache = staticmethod(replaced_by_pep8("resetCache", reset_cache)) + setResultsName = replaced_by_pep8("setResultsName", set_results_name) setBreak = replaced_by_pep8("setBreak", set_break) setParseAction = replaced_by_pep8("setParseAction", set_parse_action) @@ -2275,8 +2294,6 @@ class ParserElement(ABC): addCondition = replaced_by_pep8("addCondition", add_condition) setFailAction = replaced_by_pep8("setFailAction", set_fail_action) tryParse = replaced_by_pep8("tryParse", try_parse) - enableLeftRecursion = replaced_by_pep8("enableLeftRecursion", enable_left_recursion) - enablePackrat = replaced_by_pep8("enablePackrat", enable_packrat) parseString = replaced_by_pep8("parseString", parse_string) scanString = replaced_by_pep8("scanString", scan_string) transformString = replaced_by_pep8("transformString", transform_string) @@ -2290,8 +2307,7 @@ class ParserElement(ABC): setName = replaced_by_pep8("setName", set_name) parseFile = replaced_by_pep8("parseFile", parse_file) runTests = replaced_by_pep8("runTests", run_tests) - canParseNext = can_parse_next - resetCache = reset_cache + canParseNext = replaced_by_pep8("canParseNext", can_parse_next) defaultName = default_name # fmt: on @@ -2331,7 +2347,7 @@ class _PendingSkip(ParserElement): def __repr__(self): return self.defaultName - def parseImpl(self, *args): + def parseImpl(self, *args) -> ParseImplReturnType: raise Exception( "use of `...` expression without following SkipTo target expression" ) @@ -2360,7 +2376,7 @@ class NoMatch(Token): self.mayIndexError = False self.errmsg = "Unmatchable token" - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: raise ParseException(instring, loc, self.errmsg, self) @@ -2409,7 +2425,7 @@ class Literal(Token): def _generateDefaultName(self) -> str: return repr(self.match) - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if instring[loc] == self.firstMatchChar and instring.startswith( self.match, loc ): @@ -2430,12 +2446,12 @@ class Empty(Literal): def _generateDefaultName(self) -> str: return "Empty" - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: return loc, [] class _SingleCharLiteral(Literal): - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if instring[loc] == self.firstMatchChar: return loc + 1, self.match raise ParseException(instring, loc, self.errmsg, self) @@ -2505,7 +2521,7 @@ class Keyword(Token): def _generateDefaultName(self) -> str: return repr(self.match) - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: errmsg = self.errmsg errloc = loc if self.caseless: @@ -2556,7 +2572,10 @@ class Keyword(Token): """ Keyword.DEFAULT_KEYWORD_CHARS = chars - setDefaultKeywordChars = set_default_keyword_chars + # Compatibility synonyms + setDefaultKeywordChars = staticmethod( + replaced_by_pep8("setDefaultKeywordChars", set_default_keyword_chars) + ) class CaselessLiteral(Literal): @@ -2580,7 +2599,7 @@ class CaselessLiteral(Literal): self.returnString = match_string self.errmsg = f"Expected {self.name}" - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if instring[loc : loc + self.matchLen].upper() == self.match: return loc + self.matchLen, self.returnString raise ParseException(instring, loc, self.errmsg, self) @@ -2666,7 +2685,7 @@ class CloseMatch(Token): def _generateDefaultName(self) -> str: return f"{type(self).__name__}:{self.match_string!r}" - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: start = loc instrlen = len(instring) maxloc = start + len(self.match_string) @@ -2911,36 +2930,36 @@ class Word(Token): return base + f"{{{self.minLen},{self.maxLen}}}" return base - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if instring[loc] not in self.initChars: raise ParseException(instring, loc, self.errmsg, self) start = loc loc += 1 instrlen = len(instring) - bodychars = self.bodyChars + body_chars: set[str] = self.bodyChars maxloc = start + self.maxLen maxloc = min(maxloc, instrlen) - while loc < maxloc and instring[loc] in bodychars: + while loc < maxloc and instring[loc] in body_chars: loc += 1 - throwException = False + throw_exception = False if loc - start < self.minLen: - throwException = True - elif self.maxSpecified and loc < instrlen and instring[loc] in bodychars: - throwException = True + throw_exception = True + elif self.maxSpecified and loc < instrlen and instring[loc] in body_chars: + throw_exception = True elif self.asKeyword and ( - (start > 0 and instring[start - 1] in bodychars) - or (loc < instrlen and instring[loc] in bodychars) + (start > 0 and instring[start - 1] in body_chars) + or (loc < instrlen and instring[loc] in body_chars) ): - throwException = True + throw_exception = True - if throwException: + if throw_exception: raise ParseException(instring, loc, self.errmsg, self) return loc, instring[start:loc] - def parseImpl_regex(self, instring, loc, doActions=True): + def parseImpl_regex(self, instring, loc, do_actions=True) -> ParseImplReturnType: result = self.re_match(instring, loc) if not result: raise ParseException(instring, loc, self.errmsg, self) @@ -3043,7 +3062,7 @@ class Regex(Token): self.parseImpl = self.parseImplAsMatch # type: ignore [assignment] @cached_property - def re(self): + def re(self) -> _RePattern: if self._re: return self._re @@ -3053,17 +3072,18 @@ class Regex(Token): raise ValueError(f"invalid pattern ({self.pattern!r}) passed to Regex") @cached_property - def re_match(self): + def re_match(self) -> Callable[[str], Any]: return self.re.match @cached_property - def mayReturnEmpty(self): + def mayReturnEmpty(self) -> bool: return self.re_match("") is not None def _generateDefaultName(self) -> str: - return "Re:({})".format(repr(self.pattern).replace("\\\\", "\\")) + unescaped = self.pattern.replace("\\\\", "\\") + return f"Re:({unescaped!r})" - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: result = self.re_match(instring, loc) if not result: raise ParseException(instring, loc, self.errmsg, self) @@ -3077,7 +3097,7 @@ class Regex(Token): return loc, ret - def parseImplAsGroupList(self, instring, loc, doActions=True): + def parseImplAsGroupList(self, instring, loc, do_actions=True): result = self.re_match(instring, loc) if not result: raise ParseException(instring, loc, self.errmsg, self) @@ -3086,7 +3106,7 @@ class Regex(Token): ret = result.groups() return loc, ret - def parseImplAsMatch(self, instring, loc, doActions=True): + def parseImplAsMatch(self, instring, loc, do_actions=True): result = self.re_match(instring, loc) if not result: raise ParseException(instring, loc, self.errmsg, self) @@ -3223,7 +3243,7 @@ class QuotedString(Token): # fmt: off # build up re pattern for the content between the quote delimiters - inner_pattern = [] + inner_pattern: List[str] = [] if esc_quote: inner_pattern.append(rf"(?:{re.escape(esc_quote)})") @@ -3245,12 +3265,12 @@ class QuotedString(Token): self.re_flags |= re.MULTILINE | re.DOTALL inner_pattern.append( rf"(?:[^{_escape_regex_range_chars(self.end_quote_char[0])}" - rf"{(_escape_regex_range_chars(esc_char) if self.has_esc_char else '')}])" + rf"{(_escape_regex_range_chars(self.esc_char) if self.has_esc_char else '')}])" ) else: inner_pattern.append( rf"(?:[^{_escape_regex_range_chars(self.end_quote_char[0])}\n\r" - rf"{(_escape_regex_range_chars(esc_char) if self.has_esc_char else '')}])" + rf"{(_escape_regex_range_chars(self.esc_char) if self.has_esc_char else '')}])" ) self.pattern = "".join( @@ -3298,7 +3318,7 @@ class QuotedString(Token): return f"quoted string, starting with {self.quote_char} ending with {self.end_quote_char}" - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: # check first character of opening quote to see if that is a match # before doing the more complicated regex match result = ( @@ -3412,7 +3432,7 @@ class CharsNotIn(Token): else: return f"!W:({self.notChars})" - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: notchars = self.notCharsSet if instring[loc] in notchars: raise ParseException(instring, loc, self.errmsg, self) @@ -3490,7 +3510,7 @@ class White(Token): def _generateDefaultName(self) -> str: return "".join(White.whiteStrs[c] for c in self.matchWhite) - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if instring[loc] not in self.matchWhite: raise ParseException(instring, loc, self.errmsg, self) start = loc @@ -3538,7 +3558,7 @@ class GoToColumn(PositionToken): return loc - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: thiscol = col(loc, instring) if thiscol > self.col: raise ParseException(instring, loc, "Text not in expected column", self) @@ -3576,7 +3596,7 @@ class LineStart(PositionToken): self.orig_whiteChars = set() | self.whiteChars self.whiteChars.discard("\n") self.skipper = Empty().set_whitespace_chars(self.whiteChars) - self.errmsg = "Expected start of line" + self.set_name("start of line") def preParse(self, instring: str, loc: int) -> int: if loc == 0: @@ -3590,7 +3610,7 @@ class LineStart(PositionToken): return ret - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if col(loc, instring) == 1: return loc, [] raise ParseException(instring, loc, self.errmsg, self) @@ -3605,9 +3625,9 @@ class LineEnd(PositionToken): super().__init__() self.whiteChars.discard("\n") self.set_whitespace_chars(self.whiteChars, copy_defaults=False) - self.errmsg = "Expected end of line" + self.set_name("end of line") - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if loc < len(instring): if instring[loc] == "\n": return loc + 1, "\n" @@ -3626,9 +3646,9 @@ class StringStart(PositionToken): def __init__(self): super().__init__() - self.errmsg = "Expected start of text" + self.set_name("start of text") - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: # see if entire string up to here is just whitespace and ignoreables if loc != 0 and loc != self.preParse(instring, 0): raise ParseException(instring, loc, self.errmsg, self) @@ -3643,9 +3663,9 @@ class StringEnd(PositionToken): def __init__(self): super().__init__() - self.errmsg = "Expected end of text" + self.set_name("end of text") - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if loc < len(instring): raise ParseException(instring, loc, self.errmsg, self) if loc == len(instring): @@ -3670,9 +3690,9 @@ class WordStart(PositionToken): wordChars = word_chars if wordChars == printables else wordChars super().__init__() self.wordChars = set(wordChars) - self.errmsg = "Not at the start of a word" + self.set_name("start of a word") - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if loc != 0: if ( instring[loc - 1] in self.wordChars @@ -3696,9 +3716,9 @@ class WordEnd(PositionToken): super().__init__() self.wordChars = set(wordChars) self.skipWhitespace = False - self.errmsg = "Not at the end of a word" + self.set_name("end of a word") - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: instrlen = len(instring) if instrlen > 0 and loc < instrlen: if ( @@ -3709,6 +3729,47 @@ class WordEnd(PositionToken): return loc, [] +class Tag(Token): + """ + A meta-element for inserting a named result into the parsed + tokens that may be checked later in a parse action or while + processing the parsed results. Accepts an optional tag value, + defaulting to `True`. + + Example:: + + end_punc = "." | ("!" + Tag("enthusiastic"))) + greeting = "Hello," + Word(alphas) + end_punc + + result = greeting.parse_string("Hello, World.") + print(result.dump()) + + result = greeting.parse_string("Hello, World!") + print(result.dump()) + + prints:: + + ['Hello,', 'World', '.'] + + ['Hello,', 'World', '!'] + - enthusiastic: True + """ + def __init__(self, tag_name: str, value: Any = True): + super().__init__() + self.mayReturnEmpty = True + self.mayIndexError = False + self.leave_whitespace() + self.tag_name = tag_name + self.tag_value = value + self.add_parse_action(self._add_tag) + + def _add_tag(self, tokens: ParseResults): + tokens[self.tag_name] = self.tag_value + + def _generateDefaultName(self) -> str: + return f"{type(self).__name__}:{self.tag_name}={self.tag_value!r}" + + class ParseExpression(ParserElement): """Abstract subclass of ParserElement, for combining and post-processing parsed tokens. @@ -3846,13 +3907,13 @@ class ParseExpression(ParserElement): ret.exprs = [e.copy() for e in self.exprs] return ret - def _setResultsName(self, name, listAllMatches=False): + def _setResultsName(self, name, list_all_matches=False) -> ParserElement: if not ( __diag__.warn_ungrouped_named_tokens_in_collection and Diagnostics.warn_ungrouped_named_tokens_in_collection not in self.suppress_warnings_ ): - return super()._setResultsName(name, listAllMatches) + return super()._setResultsName(name, list_all_matches) for e in self.exprs: if ( @@ -3871,7 +3932,7 @@ class ParseExpression(ParserElement): warnings.warn(warning, stacklevel=3) break - return super()._setResultsName(name, listAllMatches) + return super()._setResultsName(name, list_all_matches) # Compatibility synonyms # fmt: off @@ -3911,7 +3972,7 @@ class And(ParseExpression): ): exprs: List[ParserElement] = list(exprs_arg) if exprs and Ellipsis in exprs: - tmp = [] + tmp: List[ParserElement] = [] for i, expr in enumerate(exprs): if expr is not Ellipsis: tmp.append(expr) @@ -3991,11 +4052,11 @@ class And(ParseExpression): self.mayReturnEmpty = all(e.mayReturnEmpty for e in self.exprs) return self - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True): # pass False as callPreParse arg to _parse for first element, since we already # pre-parsed the string as part of our And pre-parsing loc, resultlist = self.exprs[0]._parse( - instring, loc, doActions, callPreParse=False + instring, loc, do_actions, callPreParse=False ) errorStop = False for e in self.exprs[1:]: @@ -4005,7 +4066,7 @@ class And(ParseExpression): continue if errorStop: try: - loc, exprtokens = e._parse(instring, loc, doActions) + loc, exprtokens = e._parse(instring, loc, do_actions) except ParseSyntaxException: raise except ParseBaseException as pe: @@ -4016,7 +4077,7 @@ class And(ParseExpression): instring, len(instring), self.errmsg, self ) else: - loc, exprtokens = e._parse(instring, loc, doActions) + loc, exprtokens = e._parse(instring, loc, do_actions) resultlist += exprtokens return loc, resultlist @@ -4080,11 +4141,11 @@ class Or(ParseExpression): self.saveAsList = False return self - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: maxExcLoc = -1 maxException = None - matches = [] - fatals = [] + matches: List[Tuple[int, ParserElement]] = [] + fatals: List[ParseFatalException] = [] if all(e.callPreparse for e in self.exprs): loc = self.preParse(instring, loc) for e in self.exprs: @@ -4117,11 +4178,11 @@ class Or(ParseExpression): # might change whether or how much they match of the input. matches.sort(key=itemgetter(0), reverse=True) - if not doActions: + if not do_actions: # no further conditions or parse actions to change the selection of # alternative, so the first match will be the best match best_expr = matches[0][1] - return best_expr._parse(instring, loc, doActions) + return best_expr._parse(instring, loc, do_actions) longest = -1, None for loc1, expr1 in matches: @@ -4130,7 +4191,7 @@ class Or(ParseExpression): return longest try: - loc2, toks = expr1._parse(instring, loc, doActions) + loc2, toks = expr1._parse(instring, loc, do_actions) except ParseException as err: err.__traceback__ = None if err.loc > maxExcLoc: @@ -4173,7 +4234,7 @@ class Or(ParseExpression): def _generateDefaultName(self) -> str: return f"{{{' ^ '.join(str(e) for e in self.exprs)}}}" - def _setResultsName(self, name, listAllMatches=False): + def _setResultsName(self, name, list_all_matches=False) -> ParserElement: if ( __diag__.warn_multiple_tokens_in_named_alternation and Diagnostics.warn_multiple_tokens_in_named_alternation @@ -4194,7 +4255,7 @@ class Or(ParseExpression): ) warnings.warn(warning, stacklevel=3) - return super()._setResultsName(name, listAllMatches) + return super()._setResultsName(name, list_all_matches) class MatchFirst(ParseExpression): @@ -4239,13 +4300,13 @@ class MatchFirst(ParseExpression): self.mayReturnEmpty = True return self - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: maxExcLoc = -1 maxException = None for e in self.exprs: try: - return e._parse(instring, loc, doActions) + return e._parse(instring, loc, do_actions) except ParseFatalException as pfe: pfe.__traceback__ = None pfe.parser_element = e @@ -4280,7 +4341,7 @@ class MatchFirst(ParseExpression): def _generateDefaultName(self) -> str: return f"{{{' | '.join(str(e) for e in self.exprs)}}}" - def _setResultsName(self, name, listAllMatches=False): + def _setResultsName(self, name, list_all_matches=False) -> ParserElement: if ( __diag__.warn_multiple_tokens_in_named_alternation and Diagnostics.warn_multiple_tokens_in_named_alternation @@ -4301,7 +4362,7 @@ class MatchFirst(ParseExpression): ) warnings.warn(warning, stacklevel=3) - return super()._setResultsName(name, listAllMatches) + return super()._setResultsName(name, list_all_matches) class Each(ParseExpression): @@ -4387,7 +4448,7 @@ class Each(ParseExpression): self.mayReturnEmpty = True return self - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if self.initExprGroups: self.opt1map = dict( (id(e.expr), e) for e in self.exprs if isinstance(e, Opt) @@ -4419,11 +4480,11 @@ class Each(ParseExpression): tmpReqd = self.required[:] tmpOpt = self.optionals[:] multis = self.multioptionals[:] - matchOrder = [] + matchOrder: List[ParserElement] = [] keepMatching = True - failed = [] - fatals = [] + failed: List[ParserElement] = [] + fatals: List[ParseFatalException] = [] while keepMatching: tmpExprs = tmpReqd + tmpOpt + multis failed.clear() @@ -4469,7 +4530,7 @@ class Each(ParseExpression): total_results = ParseResults([]) for e in matchOrder: - loc, results = e._parse(instring, loc, doActions) + loc, results = e._parse(instring, loc, do_actions) total_results += results return loc, total_results @@ -4509,12 +4570,14 @@ class ParseElementEnhance(ParserElement): def recurse(self) -> List[ParserElement]: return [self.expr] if self.expr is not None else [] - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True): if self.expr is None: raise ParseException(instring, loc, "No expression defined", self) try: - return self.expr._parse(instring, loc, doActions, callPreParse=False) + return self.expr._parse(instring, loc, do_actions, callPreParse=False) + except ParseSyntaxException: + raise except ParseBaseException as pbe: if not isinstance(self, Forward) or self.customName is not None: if self.errmsg: @@ -4611,14 +4674,14 @@ class IndentedBlock(ParseElementEnhance): self._grouped = grouped self.parent_anchor = 1 - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: # advance parse position to non-whitespace by using an Empty() # this should be the column to be used for all subsequent indented lines anchor_loc = Empty().preParse(instring, loc) # see if self.expr matches at the current location - if not it will raise an exception # and no further work is necessary - self.expr.try_parse(instring, anchor_loc, do_actions=doActions) + self.expr.try_parse(instring, anchor_loc, do_actions=do_actions) indent_col = col(anchor_loc, instring) peer_detect_expr = self._Indent(indent_col) @@ -4643,7 +4706,7 @@ class IndentedBlock(ParseElementEnhance): else: wrapper = lambda expr: expr return (wrapper(block) + Optional(trailing_undent)).parseImpl( - instring, anchor_loc, doActions + instring, anchor_loc, do_actions ) @@ -4662,10 +4725,10 @@ class AtStringStart(ParseElementEnhance): super().__init__(expr) self.callPreparse = False - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if loc != 0: raise ParseException(instring, loc, "not found at string start") - return super().parseImpl(instring, loc, doActions) + return super().parseImpl(instring, loc, do_actions) class AtLineStart(ParseElementEnhance): @@ -4695,10 +4758,10 @@ class AtLineStart(ParseElementEnhance): super().__init__(expr) self.callPreparse = False - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if col(loc, instring) != 1: raise ParseException(instring, loc, "not found at line start") - return super().parseImpl(instring, loc, doActions) + return super().parseImpl(instring, loc, do_actions) class FollowedBy(ParseElementEnhance): @@ -4728,10 +4791,10 @@ class FollowedBy(ParseElementEnhance): super().__init__(expr) self.mayReturnEmpty = True - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: # by using self._expr.parse and deleting the contents of the returned ParseResults list # we keep any named results that were defined in the FollowedBy expression - _, ret = self.expr._parse(instring, loc, doActions=doActions) + _, ret = self.expr._parse(instring, loc, do_actions=do_actions) del ret[:] return loc, ret @@ -4793,7 +4856,7 @@ class PrecededBy(ParseElementEnhance): self.skipWhitespace = False self.parseAction.append(lambda s, l, t: t.__delitem__(slice(None, None))) - def parseImpl(self, instring, loc=0, doActions=True): + def parseImpl(self, instring, loc=0, do_actions=True) -> ParseImplReturnType: if self.exact: if loc < self.retreat: raise ParseException(instring, loc, self.errmsg) @@ -4848,9 +4911,9 @@ class Located(ParseElementEnhance): """ - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: start = loc - loc, tokens = self.expr._parse(instring, start, doActions, callPreParse=False) + loc, tokens = self.expr._parse(instring, start, do_actions, callPreParse=False) ret_tokens = ParseResults([start, tokens, loc]) ret_tokens["locn_start"] = start ret_tokens["value"] = tokens @@ -4896,8 +4959,8 @@ class NotAny(ParseElementEnhance): self.mayReturnEmpty = True self.errmsg = f"Found unwanted token, {self.expr}" - def parseImpl(self, instring, loc, doActions=True): - if self.expr.can_parse_next(instring, loc, do_actions=doActions): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: + if self.expr.can_parse_next(instring, loc, do_actions=do_actions): raise ParseException(instring, loc, self.errmsg, self) return loc, [] @@ -4927,7 +4990,7 @@ class _MultipleMatch(ParseElementEnhance): self.not_ender = ~ender if ender is not None else None return self - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: self_expr_parse = self.expr._parse self_skip_ignorables = self._skipIgnorables check_ender = self.not_ender is not None @@ -4938,7 +5001,7 @@ class _MultipleMatch(ParseElementEnhance): # if so, fail) if check_ender: try_not_ender(instring, loc) - loc, tokens = self_expr_parse(instring, loc, doActions) + loc, tokens = self_expr_parse(instring, loc, do_actions) try: hasIgnoreExprs = not not self.ignoreExprs while 1: @@ -4948,14 +5011,14 @@ class _MultipleMatch(ParseElementEnhance): preloc = self_skip_ignorables(instring, loc) else: preloc = loc - loc, tmptokens = self_expr_parse(instring, preloc, doActions) + loc, tmptokens = self_expr_parse(instring, preloc, do_actions) tokens += tmptokens except (ParseException, IndexError): pass return loc, tokens - def _setResultsName(self, name, listAllMatches=False): + def _setResultsName(self, name, list_all_matches=False) -> ParserElement: if ( __diag__.warn_ungrouped_named_tokens_in_collection and Diagnostics.warn_ungrouped_named_tokens_in_collection @@ -4978,7 +5041,7 @@ class _MultipleMatch(ParseElementEnhance): warnings.warn(warning, stacklevel=3) break - return super()._setResultsName(name, listAllMatches) + return super()._setResultsName(name, list_all_matches) class OneOrMore(_MultipleMatch): @@ -5037,9 +5100,9 @@ class ZeroOrMore(_MultipleMatch): super().__init__(expr, stopOn=stopOn or stop_on) self.mayReturnEmpty = True - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: try: - return super().parseImpl(instring, loc, doActions) + return super().parseImpl(instring, loc, do_actions) except (ParseException, IndexError): return loc, ParseResults([], name=self.resultsName) @@ -5170,10 +5233,10 @@ class Opt(ParseElementEnhance): self.defaultValue = default self.mayReturnEmpty = True - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: self_expr = self.expr try: - loc, tokens = self_expr._parse(instring, loc, doActions, callPreParse=False) + loc, tokens = self_expr._parse(instring, loc, do_actions, callPreParse=False) except (ParseException, IndexError): default_value = self.defaultValue if default_value is not self.__optionalNotMatched: @@ -5279,7 +5342,7 @@ class SkipTo(ParseElementEnhance): self.failOn = self._literalStringClass(failOn) else: self.failOn = failOn - self.errmsg = "No match found for " + str(self.expr) + self.errmsg = f"No match found for {self.expr}" self.ignorer = Empty().leave_whitespace() self._update_ignorer() @@ -5295,7 +5358,7 @@ class SkipTo(ParseElementEnhance): super().ignore(expr) self._update_ignorer() - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True): startloc = loc instrlen = len(instring) self_expr_parse = self.expr._parse @@ -5325,7 +5388,7 @@ class SkipTo(ParseElementEnhance): prev_tmploc = tmploc try: - self_expr_parse(instring, tmploc, doActions=False, callPreParse=False) + self_expr_parse(instring, tmploc, do_actions=False, callPreParse=False) except (ParseException, IndexError): # no match, advance loc in string tmploc += 1 @@ -5343,7 +5406,7 @@ class SkipTo(ParseElementEnhance): skipresult = ParseResults(skiptext) if self.includeMatch: - loc, mat = self_expr_parse(instring, loc, doActions, callPreParse=False) + loc, mat = self_expr_parse(instring, loc, do_actions, callPreParse=False) skipresult += mat return loc, skipresult @@ -5440,7 +5503,7 @@ class Forward(ParseElementEnhance): lineno=self.caller_frame.lineno, ) - def parseImpl(self, instring, loc, doActions=True): + def parseImpl(self, instring, loc, do_actions=True) -> ParseImplReturnType: if ( self.expr is None and __diag__.warn_on_parse_using_empty_Forward @@ -5466,7 +5529,7 @@ class Forward(ParseElementEnhance): stacklevel=stacklevel, ) if not ParserElement._left_recursion_enabled: - return super().parseImpl(instring, loc, doActions) + return super().parseImpl(instring, loc, do_actions) # ## Bounded Recursion algorithm ## # Recursion only needs to be processed at ``Forward`` elements, since they are # the only ones that can actually refer to themselves. The general idea is @@ -5484,13 +5547,13 @@ class Forward(ParseElementEnhance): # # There is a complication since we not only *parse* but also *transform* via # actions: We do not want to run the actions too often while expanding. Thus, - # we expand using `doActions=False` and only run `doActions=True` if the next + # we expand using `do_actions=False` and only run `do_actions=True` if the next # recursion level is acceptable. with ParserElement.recursion_lock: memo = ParserElement.recursion_memos try: # we are parsing at a specific recursion expansion - use it as-is - prev_loc, prev_result = memo[loc, self, doActions] + prev_loc, prev_result = memo[loc, self, do_actions] if isinstance(prev_result, Exception): raise prev_result return prev_loc, prev_result.copy() @@ -5498,14 +5561,14 @@ class Forward(ParseElementEnhance): act_key = (loc, self, True) peek_key = (loc, self, False) # we are searching for the best recursion expansion - keep on improving - # both `doActions` cases must be tracked separately here! + # both `do_actions` cases must be tracked separately here! prev_loc, prev_peek = memo[peek_key] = ( loc - 1, ParseException( instring, loc, "Forward recursion without base case", self ), ) - if doActions: + if do_actions: memo[act_key] = memo[peek_key] while True: try: @@ -5517,8 +5580,8 @@ class Forward(ParseElementEnhance): new_loc, new_peek = prev_loc, prev_peek # the match did not get better: we are done if new_loc <= prev_loc: - if doActions: - # replace the match for doActions=False as well, + if do_actions: + # replace the match for do_actions=False as well, # in case the action did backtrack prev_loc, prev_result = memo[peek_key] = memo[act_key] del memo[peek_key], memo[act_key] @@ -5526,7 +5589,7 @@ class Forward(ParseElementEnhance): del memo[peek_key] return prev_loc, prev_peek.copy() # the match did get better: see if we can improve further - if doActions: + if do_actions: try: memo[act_key] = super().parseImpl(instring, loc, True) except ParseException as e: @@ -5586,7 +5649,7 @@ class Forward(ParseElementEnhance): ret <<= self return ret - def _setResultsName(self, name, list_all_matches=False): + def _setResultsName(self, name, list_all_matches=False) -> ParserElement: # fmt: off if ( __diag__.warn_name_set_on_empty_Forward @@ -5892,7 +5955,9 @@ def trace_parse_action(f: ParseAction) -> ParseAction: try: ret = f(*paArgs) except Exception as exc: - sys.stderr.write(f"< {% endif %} + {{ body | safe }} {% for diagram in diagrams %}
      @@ -89,7 +90,7 @@ class AnnotatedItem(railroad.Group): """ def __init__(self, label: str, item): - super().__init__(item=item, label="[{}]".format(label) if label else label) + super().__init__(item=item, label=f"[{label}]") class EditablePartial(Generic[T]): @@ -145,7 +146,7 @@ def railroad_to_html(diagrams: List[NamedDiagram], embed=False, **kwargs) -> str continue io = StringIO() try: - css = kwargs.get('css') + css = kwargs.get("css") diagram.diagram.writeStandalone(io.write, css=css) except AttributeError: diagram.diagram.writeSvg(io.write) @@ -425,9 +426,11 @@ def _apply_diagram_item_enhancements(fn): element_results_name = element.resultsName if element_results_name: # add "*" to indicate if this is a "list all results" name - element_results_name += "" if element.modalResults else "*" + modal_tag = "" if element.modalResults else "*" ret = EditablePartial.from_call( - railroad.Group, item=ret, label=element_results_name + railroad.Group, + item=ret, + label=f"{repr(element_results_name)}{modal_tag}", ) return ret @@ -534,7 +537,7 @@ def _to_diagram_element( # (all will have the same name, and resultsName) if not exprs: return None - if len(set((e.name, e.resultsName) for e in exprs)) == 1: + if len(set((e.name, e.resultsName) for e in exprs)) == 1 and len(exprs) > 2: ret = EditablePartial.from_call( railroad.OneOrMore, item="", repeat=str(len(exprs)) ) @@ -563,7 +566,7 @@ def _to_diagram_element( if show_groups: ret = EditablePartial.from_call(AnnotatedItem, label="", item="") else: - ret = EditablePartial.from_call(railroad.Group, label="", item="") + ret = EditablePartial.from_call(railroad.Sequence, items=[]) elif isinstance(element, pyparsing.TokenConverter): label = type(element).__name__.lower() if label == "tokenconverter": @@ -573,8 +576,36 @@ def _to_diagram_element( elif isinstance(element, pyparsing.Opt): ret = EditablePartial.from_call(railroad.Optional, item="") elif isinstance(element, pyparsing.OneOrMore): - ret = EditablePartial.from_call(railroad.OneOrMore, item="") + if element.not_ender is not None: + args = [ + parent, + lookup, + vertical, + index, + name_hint, + show_results_names, + show_groups, + ] + return _to_diagram_element( + (~element.not_ender.expr + element.expr)[1, ...].set_name(element.name), + *args, + ) + ret = EditablePartial.from_call(railroad.OneOrMore, item=None) elif isinstance(element, pyparsing.ZeroOrMore): + if element.not_ender is not None: + args = [ + parent, + lookup, + vertical, + index, + name_hint, + show_results_names, + show_groups, + ] + return _to_diagram_element( + (~element.not_ender.expr + element.expr)[...].set_name(element.name), + *args, + ) ret = EditablePartial.from_call(railroad.ZeroOrMore, item="") elif isinstance(element, pyparsing.Group): ret = EditablePartial.from_call( diff --git a/lib/pyparsing/exceptions.py b/lib/pyparsing/exceptions.py index 1aaea56f..8db34f19 100644 --- a/lib/pyparsing/exceptions.py +++ b/lib/pyparsing/exceptions.py @@ -85,7 +85,7 @@ class ParseBaseException(Exception): ret = [] if isinstance(exc, ParseBaseException): ret.append(exc.line) - ret.append(" " * (exc.column - 1) + "^") + ret.append(f"{' ' * (exc.column - 1)}^") ret.append(f"{type(exc).__name__}: {exc}") if depth <= 0: @@ -245,6 +245,7 @@ class ParseBaseException(Exception): """ return self.explain_exception(self, depth) + # Compatibility synonyms # fmt: off markInputline = replaced_by_pep8("markInputline", mark_input_line) # fmt: on diff --git a/lib/pyparsing/helpers.py b/lib/pyparsing/helpers.py index dcfdb8fe..d5d14a08 100644 --- a/lib/pyparsing/helpers.py +++ b/lib/pyparsing/helpers.py @@ -782,9 +782,12 @@ def infix_notation( # if lpar and rpar are not suppressed, wrap in group if not (isinstance(lpar, Suppress) and isinstance(rpar, Suppress)): - lastExpr = base_expr | Group(lpar + ret + rpar) + lastExpr = base_expr | Group(lpar + ret + rpar).set_name( + f"nested_{base_expr.name}" + ) else: - lastExpr = base_expr | (lpar + ret + rpar) + lastExpr = base_expr | (lpar + ret + rpar).set_name(f"nested_{base_expr.name}") + root_expr = lastExpr arity: int rightLeftAssoc: opAssoc @@ -855,6 +858,7 @@ def infix_notation( thisExpr <<= (matchExpr | lastExpr).setName(term_name) lastExpr = thisExpr ret <<= lastExpr + root_expr.set_name("base_expr") return ret @@ -1049,7 +1053,7 @@ def delimited_list( ) -# pre-PEP8 compatible names +# Compatibility synonyms # fmt: off opAssoc = OpAssoc anyOpenTag = any_open_tag diff --git a/lib/pyparsing/results.py b/lib/pyparsing/results.py index 3e5fe208..3bb7c948 100644 --- a/lib/pyparsing/results.py +++ b/lib/pyparsing/results.py @@ -4,12 +4,14 @@ from collections.abc import ( Mapping, MutableSequence, Iterator, - Sequence, - Container, + Iterable, ) import pprint from typing import Tuple, Any, Dict, Set, List +from .util import replaced_by_pep8 + + str_type: Tuple[type, ...] = (str, bytes) _generator_type = type((_ for _ in ())) @@ -573,20 +575,20 @@ class ParseResults: # replace values with copies if they are of known mutable types for i, obj in enumerate(self._toklist): if isinstance(obj, ParseResults): - self._toklist[i] = obj.deepcopy() + ret._toklist[i] = obj.deepcopy() elif isinstance(obj, (str, bytes)): pass elif isinstance(obj, MutableMapping): - self._toklist[i] = dest = type(obj)() + ret._toklist[i] = dest = type(obj)() for k, v in obj.items(): dest[k] = v.deepcopy() if isinstance(v, ParseResults) else v - elif isinstance(obj, Container): - self._toklist[i] = type(obj)( + elif isinstance(obj, Iterable): + ret._toklist[i] = type(obj)( v.deepcopy() if isinstance(v, ParseResults) else v for v in obj ) return ret - def get_name(self): + def get_name(self) -> str: r""" Returns the results name for this token expression. Useful when several different expressions might match at a particular location. diff --git a/lib/pyparsing/unicode.py b/lib/pyparsing/unicode.py index 426b8b23..0e3e0657 100644 --- a/lib/pyparsing/unicode.py +++ b/lib/pyparsing/unicode.py @@ -53,51 +53,51 @@ class unicode_set: _ranges: UnicodeRangeList = [] @_lazyclassproperty - def _chars_for_ranges(cls): - ret = [] + def _chars_for_ranges(cls) -> List[str]: + ret: List[int] = [] for cc in cls.__mro__: if cc is unicode_set: break for rr in getattr(cc, "_ranges", ()): ret.extend(range(rr[0], rr[-1] + 1)) - return [chr(c) for c in sorted(set(ret))] + return sorted(chr(c) for c in set(ret)) @_lazyclassproperty - def printables(cls): + def printables(cls) -> str: """all non-whitespace characters in this range""" return "".join(filterfalse(str.isspace, cls._chars_for_ranges)) @_lazyclassproperty - def alphas(cls): + def alphas(cls) -> str: """all alphabetic characters in this range""" return "".join(filter(str.isalpha, cls._chars_for_ranges)) @_lazyclassproperty - def nums(cls): + def nums(cls) -> str: """all numeric digit characters in this range""" return "".join(filter(str.isdigit, cls._chars_for_ranges)) @_lazyclassproperty - def alphanums(cls): + def alphanums(cls) -> str: """all alphanumeric characters in this range""" return cls.alphas + cls.nums @_lazyclassproperty - def identchars(cls): + def identchars(cls) -> str: """all characters in this range that are valid identifier characters, plus underscore '_'""" return "".join( sorted( - set( - "".join(filter(str.isidentifier, cls._chars_for_ranges)) - + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzªµº" - + "ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ" - + "_" + set(filter(str.isidentifier, cls._chars_for_ranges)) + | set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzªµº" + "ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ" + "_" ) ) ) @_lazyclassproperty - def identbodychars(cls): + def identbodychars(cls) -> str: """ all characters in this range that are valid identifier body characters, plus the digits 0-9, and · (Unicode MIDDLE DOT) @@ -105,7 +105,9 @@ class unicode_set: identifier_chars = set( c for c in cls._chars_for_ranges if ("_" + c).isidentifier() ) - return "".join(sorted(identifier_chars | set(cls.identchars + "0123456789·"))) + return "".join( + sorted(identifier_chars | set(cls.identchars) | set("0123456789·")) + ) @_lazyclassproperty def identifier(cls): diff --git a/lib/pyparsing/util.py b/lib/pyparsing/util.py index 4ae018a9..94837fea 100644 --- a/lib/pyparsing/util.py +++ b/lib/pyparsing/util.py @@ -246,7 +246,7 @@ def replaced_by_pep8(compat_name: str, fn: C) -> C: # (Presence of 'self' arg in signature is used by explain_exception() methods, so we take # some extra steps to add it if present in decorated function.) - if "self" == list(inspect.signature(fn).parameters)[0]: + if ["self"] == list(inspect.signature(fn).parameters)[:1]: @wraps(fn) def _inner(self, *args, **kwargs): diff --git a/lib/simplejson/__init__.py b/lib/simplejson/__init__.py index 2d1900d8..d3ff141d 100644 --- a/lib/simplejson/__init__.py +++ b/lib/simplejson/__init__.py @@ -118,7 +118,7 @@ Serializing multiple objects to JSON lines (newline-delimited JSON):: """ from __future__ import absolute_import -__version__ = '3.19.2' +__version__ = '3.19.3' __all__ = [ 'dump', 'dumps', 'load', 'loads', 'JSONDecoder', 'JSONDecodeError', 'JSONEncoder', diff --git a/package/requirements-package.txt b/package/requirements-package.txt index f5a1a5b6..7e3c7a1c 100644 --- a/package/requirements-package.txt +++ b/package/requirements-package.txt @@ -1,7 +1,7 @@ apscheduler==3.10.1 cryptography==43.0.0 importlib-metadata==8.5.0 -importlib-resources==6.4.0 +importlib-resources==6.4.5 pyinstaller==6.8.0 pyopenssl==24.2.1 diff --git a/plexpy/activity_processor.py b/plexpy/activity_processor.py index 952d8e59..db0545da 100644 --- a/plexpy/activity_processor.py +++ b/plexpy/activity_processor.py @@ -27,11 +27,10 @@ from plexpy import users class ActivityProcessor(object): - def __init__(self): - self.db = database.MonitorDatabase() - def write_session(self, session=None, notify=True): if session: + db = database.MonitorDatabase() + values = {'session_key': session.get('session_key', ''), 'session_id': session.get('session_id', ''), 'transcode_key': session.get('transcode_key', ''), @@ -149,7 +148,7 @@ class ActivityProcessor(object): keys = {'session_key': session.get('session_key', ''), 'rating_key': session.get('rating_key', '')} - result = self.db.upsert('sessions', values, keys) + result = db.upsert('sessions', values, keys) if result == 'insert': # If it's our first write then time stamp it. @@ -159,7 +158,7 @@ class ActivityProcessor(object): media_type=values['media_type'], started=started) timestamp = {'started': started, 'initial_stream': initial_stream} - self.db.upsert('sessions', timestamp, keys) + db.upsert('sessions', timestamp, keys) # Check if any notification agents have notifications enabled if notify: @@ -260,6 +259,8 @@ class ActivityProcessor(object): logger.debug("Tautulli ActivityProcessor :: History logging for library '%s' is disabled." % library_details['section_name']) if logging_enabled: + db = database.MonitorDatabase() + media_info = {} # Fetch metadata first so we can return false if it fails @@ -316,10 +317,10 @@ class ActivityProcessor(object): # logger.debug("Tautulli ActivityProcessor :: Writing sessionKey %s session_history transaction..." # % session['session_key']) - self.db.upsert(table_name='session_history', key_dict=keys, value_dict=values) + db.upsert(table_name='session_history', key_dict=keys, value_dict=values) # Get the last insert row id - last_id = self.db.last_insert_id() + last_id = db.last_insert_id() self.group_history(last_id, session, metadata) # logger.debug("Tautulli ActivityProcessor :: Successfully written history item, last id for session_history is %s" @@ -410,7 +411,7 @@ class ActivityProcessor(object): # logger.debug("Tautulli ActivityProcessor :: Writing sessionKey %s session_history_media_info transaction..." # % session['session_key']) - self.db.upsert(table_name='session_history_media_info', key_dict=keys, value_dict=values) + db.upsert(table_name='session_history_media_info', key_dict=keys, value_dict=values) # Write the session_history_metadata table directors = ";".join(metadata['directors']) @@ -475,7 +476,7 @@ class ActivityProcessor(object): # logger.debug("Tautulli ActivityProcessor :: Writing sessionKey %s session_history_metadata transaction..." # % session['session_key']) - self.db.upsert(table_name='session_history_metadata', key_dict=keys, value_dict=values) + db.upsert(table_name='session_history_metadata', key_dict=keys, value_dict=values) # Return the session row id when the session is successfully written to the database return session['id'] @@ -484,6 +485,8 @@ class ActivityProcessor(object): new_session = prev_session = None prev_watched = None + db = database.MonitorDatabase() + if session['live']: # Check if we should group the session, select the last guid from the user within the last day query = "SELECT session_history.id, session_history_metadata.guid, session_history.reference_id " \ @@ -495,7 +498,7 @@ class ActivityProcessor(object): args = [last_id, session['user_id']] - result = self.db.select(query=query, args=args) + result = db.select(query=query, args=args) if len(result) > 0: new_session = {'id': last_id, @@ -515,7 +518,7 @@ class ActivityProcessor(object): args = [last_id, session['user_id'], session['rating_key']] - result = self.db.select(query=query, args=args) + result = db.select(query=query, args=args) if len(result) > 1: new_session = {'id': result[0]['id'], @@ -558,9 +561,10 @@ class ActivityProcessor(object): logger.debug("Tautulli ActivityProcessor :: Not grouping history for sessionKey %s", session['session_key']) args = [last_id, last_id] - self.db.action(query=query, args=args) + db.action(query=query, args=args) def get_sessions(self, user_id=None, ip_address=None): + db = database.MonitorDatabase() query = "SELECT * FROM sessions" args = [] @@ -569,12 +573,13 @@ class ActivityProcessor(object): query += " WHERE user_id = ?" + ip args.append(user_id) - sessions = self.db.select(query, args) + sessions = db.select(query, args) return sessions def get_session_by_key(self, session_key=None): + db = database.MonitorDatabase() if str(session_key).isdigit(): - session = self.db.select_single("SELECT * FROM sessions " + session = db.select_single("SELECT * FROM sessions " "WHERE session_key = ? ", args=[session_key]) if session: @@ -583,8 +588,9 @@ class ActivityProcessor(object): return None def get_session_by_id(self, session_id=None): + db = database.MonitorDatabase() if session_id: - session = self.db.select_single("SELECT * FROM sessions " + session = db.select_single("SELECT * FROM sessions " "WHERE session_id = ? ", args=[session_id]) if session: @@ -593,6 +599,7 @@ class ActivityProcessor(object): return None def set_session_state(self, session_key=None, state=None, **kwargs): + db = database.MonitorDatabase() if str(session_key).isdigit(): values = {} @@ -603,21 +610,23 @@ class ActivityProcessor(object): values[k] = v keys = {'session_key': session_key} - result = self.db.upsert('sessions', values, keys) + result = db.upsert('sessions', values, keys) return result return None def delete_session(self, session_key=None, row_id=None): + db = database.MonitorDatabase() if str(session_key).isdigit(): - self.db.action("DELETE FROM sessions WHERE session_key = ?", [session_key]) + db.action("DELETE FROM sessions WHERE session_key = ?", [session_key]) elif str(row_id).isdigit(): - self.db.action("DELETE FROM sessions WHERE id = ?", [row_id]) + db.action("DELETE FROM sessions WHERE id = ?", [row_id]) def set_session_last_paused(self, session_key=None, timestamp=None): + db = database.MonitorDatabase() if str(session_key).isdigit(): - result = self.db.select("SELECT last_paused, paused_counter " + result = db.select("SELECT last_paused, paused_counter " "FROM sessions " "WHERE session_key = ?", args=[session_key]) @@ -636,17 +645,19 @@ class ActivityProcessor(object): values['paused_counter'] = paused_counter keys = {'session_key': session_key} - self.db.upsert('sessions', values, keys) + db.upsert('sessions', values, keys) def increment_session_buffer_count(self, session_key=None): + db = database.MonitorDatabase() if str(session_key).isdigit(): - self.db.action("UPDATE sessions SET buffer_count = buffer_count + 1 " + db.action("UPDATE sessions SET buffer_count = buffer_count + 1 " "WHERE session_key = ?", [session_key]) def get_session_buffer_count(self, session_key=None): + db = database.MonitorDatabase() if str(session_key).isdigit(): - buffer_count = self.db.select_single("SELECT buffer_count " + buffer_count = db.select_single("SELECT buffer_count " "FROM sessions " "WHERE session_key = ?", [session_key]) @@ -656,14 +667,16 @@ class ActivityProcessor(object): return 0 def set_session_buffer_trigger_time(self, session_key=None): + db = database.MonitorDatabase() if str(session_key).isdigit(): - self.db.action("UPDATE sessions SET buffer_last_triggered = strftime('%s', 'now') " + db.action("UPDATE sessions SET buffer_last_triggered = strftime('%s', 'now') " "WHERE session_key = ?", [session_key]) def get_session_buffer_trigger_time(self, session_key=None): + db = database.MonitorDatabase() if str(session_key).isdigit(): - last_time = self.db.select_single("SELECT buffer_last_triggered " + last_time = db.select_single("SELECT buffer_last_triggered " "FROM sessions " "WHERE session_key = ?", [session_key]) @@ -673,37 +686,43 @@ class ActivityProcessor(object): return None def set_temp_stopped(self): + db = database.MonitorDatabase() stopped_time = helpers.timestamp() - self.db.action("UPDATE sessions SET stopped = ?", [stopped_time]) + db.action("UPDATE sessions SET stopped = ?", [stopped_time]) def increment_write_attempts(self, session_key=None): + db = database.MonitorDatabase() if str(session_key).isdigit(): session = self.get_session_by_key(session_key=session_key) - self.db.action("UPDATE sessions SET write_attempts = ? WHERE session_key = ?", + db.action("UPDATE sessions SET write_attempts = ? WHERE session_key = ?", [session['write_attempts'] + 1, session_key]) def set_marker(self, session_key=None, marker_idx=None, marker_type=None): + db = database.MonitorDatabase() marker_args = [ int(marker_type == 'intro'), int(marker_type == 'commercial'), int(marker_type == 'credits') ] - self.db.action("UPDATE sessions SET intro = ?, commercial = ?, credits = ?, marker = ? " + db.action("UPDATE sessions SET intro = ?, commercial = ?, credits = ?, marker = ? " "WHERE session_key = ?", marker_args + [marker_idx, session_key]) def set_watched(self, session_key=None): - self.db.action("UPDATE sessions SET watched = ? " + db = database.MonitorDatabase() + db.action("UPDATE sessions SET watched = ? " "WHERE session_key = ?", [1, session_key]) def write_continued_session(self, user_id=None, machine_id=None, media_type=None, stopped=None): + db = database.MonitorDatabase() keys = {'user_id': user_id, 'machine_id': machine_id, 'media_type': media_type} values = {'stopped': stopped} - self.db.upsert(table_name='sessions_continued', key_dict=keys, value_dict=values) + db.upsert(table_name='sessions_continued', key_dict=keys, value_dict=values) def is_initial_stream(self, user_id=None, machine_id=None, media_type=None, started=None): - last_session = self.db.select_single("SELECT stopped " + db = database.MonitorDatabase() + last_session = db.select_single("SELECT stopped " "FROM sessions_continued " "WHERE user_id = ? AND machine_id = ? AND media_type = ? " "ORDER BY stopped DESC", @@ -717,11 +736,12 @@ class ActivityProcessor(object): logger.info("Tautulli ActivityProcessor :: Regrouping session history...") + db = database.MonitorDatabase() query = ( "SELECT * FROM session_history " "JOIN session_history_metadata ON session_history.id = session_history_metadata.id" ) - results = self.db.select(query) + results = db.select(query) count = len(results) progress = 0 diff --git a/plexpy/common.py b/plexpy/common.py index 8d68e2ad..59c8aeaa 100644 --- a/plexpy/common.py +++ b/plexpy/common.py @@ -170,6 +170,7 @@ AUDIO_CODEC_OVERRIDES = { VIDEO_RESOLUTION_OVERRIDES = { 'sd': 'SD', + '2k': '2k', '4k': '4k' } diff --git a/plexpy/helpers.py b/plexpy/helpers.py index 014f1359..fdb79830 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -24,8 +24,6 @@ from cloudinary.utils import cloudinary_url from collections import OrderedDict from datetime import date, datetime, timezone from functools import reduce, wraps -import hashlib -import imghdr from itertools import groupby from future.moves.itertools import islice, zip_longest from ipaddress import ip_address, ip_network, IPv4Address @@ -272,7 +270,8 @@ def human_duration(ms, sig='dhm', units='ms', return_seconds=300000): if return_seconds and ms < return_seconds: sig = 'dhms' - ms = ms * factors[units] + r = factors[sig[-1]] + ms = round(ms * factors[units] / r) * r d, h = divmod(ms, factors['d']) h, m = divmod(h, factors['h']) @@ -942,39 +941,6 @@ def cloudinary_transform(rating_key=None, width=1000, height=1500, opacity=100, return url -def cache_image(url, image=None): - """ - Saves an image to the cache directory. - If no image is provided, tries to return the image from the cache directory. - """ - # Create image directory if it doesn't exist - imgdir = os.path.join(plexpy.CONFIG.CACHE_DIR, 'images/') - if not os.path.exists(imgdir): - logger.debug("Tautulli Helpers :: Creating image cache directory at %s" % imgdir) - os.makedirs(imgdir) - - # Create a hash of the url to use as the filename - imghash = hashlib.md5(url).hexdigest() - imagefile = os.path.join(imgdir, imghash) - - # If an image is provided, save it to the cache directory - if image: - try: - with open(imagefile, 'wb') as cache_file: - cache_file.write(image) - except IOError as e: - logger.error("Tautulli Helpers :: Failed to cache image %s: %s" % (imagefile, e)) - - # Try to return the image from the cache directory - if os.path.isfile(imagefile): - imagetype = 'image/' + imghdr.what(os.path.abspath(imagefile)) - else: - imagefile = None - imagetype = 'image/jpeg' - - return imagefile, imagetype - - def build_datatables_json(kwargs, dt_columns, default_sort_col=None): """ Builds datatables json data diff --git a/plexpy/newsletter_handler.py b/plexpy/newsletter_handler.py index 69d646d9..05cb52fa 100644 --- a/plexpy/newsletter_handler.py +++ b/plexpy/newsletter_handler.py @@ -17,6 +17,7 @@ from io import open import os +import shlex from apscheduler.triggers.cron import CronTrigger import email.utils @@ -58,25 +59,36 @@ def schedule_newsletters(newsletter_id=None): def schedule_newsletter_job(newsletter_job_id, name='', func=None, remove_job=False, args=None, cron=None): - # apscheduler day_of_week uses 0-6 = mon-sun if cron: - cron = cron.split(' ') - cron[4] = str((int(cron[4]) - 1) % 7) if cron[4].isdigit() else cron[4] - cron = ' '.join(cron) + values = shlex.split(cron) + # apscheduler day_of_week uses 0-6 = mon-sun + values[4] = str((int(values[4]) - 1) % 7) if values[4].isdigit() else values[4] if NEWSLETTER_SCHED.get_job(newsletter_job_id): if remove_job: NEWSLETTER_SCHED.remove_job(newsletter_job_id) logger.info("Tautulli NewsletterHandler :: Removed scheduled newsletter: %s" % name) else: - NEWSLETTER_SCHED.reschedule_job( - newsletter_job_id, args=args, trigger=CronTrigger.from_crontab(cron)) - logger.info("Tautulli NewsletterHandler :: Re-scheduled newsletter: %s" % name) + try: + NEWSLETTER_SCHED.reschedule_job( + newsletter_job_id, args=args, trigger=CronTrigger( + minute=values[0], hour=values[1], day=values[2], month=values[3], day_of_week=values[4] + ) + ) + logger.info("Tautulli NewsletterHandler :: Re-scheduled newsletter: %s" % name) + except ValueError as e: + logger.error("Tautulli NewsletterHandler :: Failed to re-schedule newsletter: %s" % e) elif not remove_job: - NEWSLETTER_SCHED.add_job( - func, args=args, id=newsletter_job_id, trigger=CronTrigger.from_crontab(cron), - misfire_grace_time=None) - logger.info("Tautulli NewsletterHandler :: Scheduled newsletter: %s" % name) + try: + NEWSLETTER_SCHED.add_job( + func, args=args, id=newsletter_job_id, trigger=CronTrigger( + minute=values[0], hour=values[1], day=values[2], month=values[3], day_of_week=values[4] + ), + misfire_grace_time=None + ) + logger.info("Tautulli NewsletterHandler :: Scheduled newsletter: %s" % name) + except ValueError as e: + logger.error("Tautulli NewsletterHandler :: Failed to schedule newsletter: %s" % e) def notify(newsletter_id=None, notify_action=None, **kwargs): diff --git a/plexpy/newsletters.py b/plexpy/newsletters.py index 7641decf..b49077a2 100644 --- a/plexpy/newsletters.py +++ b/plexpy/newsletters.py @@ -586,6 +586,8 @@ class Newsletter(object): return parameters def _build_params(self): + from plexpy.notification_handler import CustomArrow + date_format = helpers.momentjs_to_arrow(plexpy.CONFIG.DATE_FORMAT) if plexpy.CONFIG.NEWSLETTER_SELF_HOSTED and plexpy.CONFIG.HTTP_BASE_URL: @@ -595,8 +597,8 @@ class Newsletter(object): parameters = { 'server_name': helpers.pms_name(), - 'start_date': self.start_date.format(date_format), - 'end_date': self.end_date.format(date_format), + 'start_date': CustomArrow(self.start_date, date_format), + 'end_date': CustomArrow(self.end_date, date_format), 'current_year': self.start_date.year, 'current_month': self.start_date.month, 'current_day': self.start_date.day, diff --git a/plexpy/version.py b/plexpy/version.py index feccd9cc..08fcf9e3 100644 --- a/plexpy/version.py +++ b/plexpy/version.py @@ -16,4 +16,4 @@ # along with Tautulli. If not, see . PLEXPY_BRANCH = "master" -PLEXPY_RELEASE_VERSION = "v2.14.4" \ No newline at end of file +PLEXPY_RELEASE_VERSION = "v2.14.6" \ No newline at end of file diff --git a/plexpy/versioncheck.py b/plexpy/versioncheck.py index 2c161dd3..9f546594 100644 --- a/plexpy/versioncheck.py +++ b/plexpy/versioncheck.py @@ -281,7 +281,7 @@ def check_github(scheduler=False, notify=False, use_cache=False): 'plexpy_update_commit': plexpy.LATEST_VERSION, 'plexpy_update_behind': plexpy.COMMITS_BEHIND}) - elif scheduler and plexpy.CONFIG.PLEXPY_AUTO_UPDATE and \ + if scheduler and plexpy.CONFIG.PLEXPY_AUTO_UPDATE and \ not plexpy.DOCKER and not plexpy.SNAP and not plexpy.FROZEN: logger.info('Running automatic update.') plexpy.shutdown(restart=True, update=True) diff --git a/requirements.txt b/requirements.txt index 8ef8532b..64daed44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ arrow==1.3.0 backports.zoneinfo==0.2.1;python_version<"3.9" beautifulsoup4==4.12.3 bleach==6.1.0 -certifi==2024.7.4 +certifi==2024.8.30 cheroot==10.0.1 cherrypy==18.10.0 cloudinary==1.41.0 @@ -17,7 +17,7 @@ html5lib==1.1 httpagentparser==1.9.5 idna==3.7 importlib-metadata==8.5.0 -importlib-resources==6.4.0 +importlib-resources==6.4.5 git+https://github.com/Tautulli/ipwhois.git@master#egg=ipwhois IPy==1.01 Mako==1.3.5 @@ -26,18 +26,18 @@ musicbrainzngs==0.7.1 packaging==24.1 paho-mqtt==2.1.0 platformdirs==4.2.2 -plexapi==4.15.15 +plexapi==4.15.16 portend==3.2.0 profilehooks==1.12.0 PyJWT==2.9.0 -pyparsing==3.1.2 +pyparsing==3.1.4 python-dateutil==2.9.0.post0 python-twitter==3.5 pytz==2024.1 requests==2.32.3 requests-oauthlib==2.0.0 rumps==0.4.0; platform_system == "Darwin" -simplejson==3.19.2 +simplejson==3.19.3 six==1.16.0 tempora==5.7.0 tokenize-rt==6.0.0 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 6f76e8cc..35c22479 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -26,14 +26,14 @@ parts: stage-packages: - python3 - python3-openssl - - python3-pycryptodome + - python3-cryptography - python3-setuptools - python3-pkg-resources build-packages: - git - python3 - python3-openssl - - python3-pycryptodome + - python3-cryptography - python3-setuptools - python3-pkg-resources override-pull: |