mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-08-24 06:55:26 -07:00
Merge branch 'nightly' into dependabot/pip/nightly/idna-3.10
This commit is contained in:
commit
d50b1e5528
134 changed files with 2141 additions and 1709 deletions
24
CHANGELOG.md
24
CHANGELOG.md
|
@ -1,5 +1,27 @@
|
||||||
# Changelog
|
# 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)
|
## v2.14.4 (2024-08-10)
|
||||||
|
|
||||||
* Notifications:
|
* Notifications:
|
||||||
|
@ -8,7 +30,7 @@
|
||||||
* UI:
|
* UI:
|
||||||
* Fix: macOS platform capitalization.
|
* Fix: macOS platform capitalization.
|
||||||
* Other:
|
* Other:
|
||||||
* Fix: Remove deprecated getdefaultlocale (Thanks @teodorstelian) (#2364, #2345)
|
* Fix: Remove deprecated getdefaultlocale. (Thanks @teodorstelian) (#2364, #2345)
|
||||||
|
|
||||||
|
|
||||||
## v2.14.3 (2024-06-19)
|
## v2.14.3 (2024-06-19)
|
||||||
|
|
|
@ -1478,7 +1478,8 @@ a:hover .dashboard-stats-square {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.dashboard-recent-media {
|
.dashboard-recent-media {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -92,10 +92,10 @@
|
||||||
<h3 class="pull-left"><span id="recently-added-xml">Recently Added</span></h3>
|
<h3 class="pull-left"><span id="recently-added-xml">Recently Added</span></h3>
|
||||||
<ul class="nav nav-header nav-dashboard pull-right" style="margin-top: -3px;">
|
<ul class="nav nav-header nav-dashboard pull-right" style="margin-top: -3px;">
|
||||||
<li>
|
<li>
|
||||||
<a href="#" id="recently-added-page-left" class="paginate btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
<a href="#" id="recently-added-page-left" class="paginate-added btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" id="recently-added-page-right" class="paginate btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
<a href="#" id="recently-added-page-right" class="paginate-added btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="button-bar">
|
<div class="button-bar">
|
||||||
|
@ -936,10 +936,14 @@
|
||||||
count: recently_added_count,
|
count: recently_added_count,
|
||||||
media_type: recently_added_type
|
media_type: recently_added_type
|
||||||
},
|
},
|
||||||
|
beforeSend: function () {
|
||||||
|
$(".dashboard-recent-media-row").animate({ scrollLeft: 0 }, 1000);
|
||||||
|
},
|
||||||
complete: function (xhr, status) {
|
complete: function (xhr, status) {
|
||||||
$("#recentlyAdded").html(xhr.responseText);
|
$("#recentlyAdded").html(xhr.responseText);
|
||||||
$('#ajaxMsg').fadeOut();
|
$('#ajaxMsg').fadeOut();
|
||||||
highlightAddedScrollerButton();
|
highlightScrollerButton("#recently-added");
|
||||||
|
paginateScroller("#recently-added", ".paginate-added");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -955,57 +959,11 @@
|
||||||
recentlyAdded(recently_added_count, recently_added_type);
|
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').on('change', function () {
|
||||||
$('#recently-added-toggles > label').removeClass('active');
|
$('#recently-added-toggles > label').removeClass('active');
|
||||||
selected_filter = $('input[name=recently-added-toggle]:checked', '#recently-added-toggles');
|
selected_filter = $('input[name=recently-added-toggle]:checked', '#recently-added-toggles');
|
||||||
$(selected_filter).closest('label').addClass('active');
|
$(selected_filter).closest('label').addClass('active');
|
||||||
recently_added_type = $(selected_filter).val();
|
recently_added_type = $(selected_filter).val();
|
||||||
resetScroller();
|
|
||||||
setLocalStorage('home_stats_recently_added_type', recently_added_type);
|
setLocalStorage('home_stats_recently_added_type', recently_added_type);
|
||||||
recentlyAdded(recently_added_count, recently_added_type);
|
recentlyAdded(recently_added_count, recently_added_type);
|
||||||
});
|
});
|
||||||
|
@ -1013,7 +971,6 @@
|
||||||
$('#recently-added-count').change(function () {
|
$('#recently-added-count').change(function () {
|
||||||
forceMinMax($(this));
|
forceMinMax($(this));
|
||||||
recently_added_count = $(this).val();
|
recently_added_count = $(this).val();
|
||||||
resetScroller();
|
|
||||||
setLocalStorage('home_stats_recently_added_count', recently_added_count);
|
setLocalStorage('home_stats_recently_added_count', recently_added_count);
|
||||||
recentlyAdded(recently_added_count, recently_added_type);
|
recentlyAdded(recently_added_count, recently_added_type);
|
||||||
});
|
});
|
||||||
|
|
|
@ -360,7 +360,8 @@ function humanDuration(ms, sig='dhm', units='ms', return_seconds=300000) {
|
||||||
sig = 'dhms'
|
sig = 'dhms'
|
||||||
}
|
}
|
||||||
|
|
||||||
ms = ms * factors[units];
|
r = factors[sig.slice(-1)];
|
||||||
|
ms = Math.round(ms * factors[units] / r) * r;
|
||||||
|
|
||||||
h = ms % factors['d'];
|
h = ms % factors['d'];
|
||||||
d = Math.trunc(ms / factors['d']);
|
d = Math.trunc(ms / factors['d']);
|
||||||
|
@ -929,3 +930,50 @@ $('.modal').on('hide.bs.modal', function (e) {
|
||||||
$.fn.hasScrollBar = function() {
|
$.fn.hasScrollBar = function() {
|
||||||
return this.get(0).scrollHeight > this.get(0).clientHeight;
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -149,10 +149,10 @@ DOCUMENTATION :: END
|
||||||
<div class="table-card-header">
|
<div class="table-card-header">
|
||||||
<ul class="nav nav-header nav-dashboard pull-right">
|
<ul class="nav nav-header nav-dashboard pull-right">
|
||||||
<li>
|
<li>
|
||||||
<a href="#" id="recently-watched-page-left" class="paginate-watched btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
<a href="#" id="recently-watched-page-left" class="paginate-watched btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" id="recently-watched-page-right" class="paginate-watched btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
<a href="#" id="recently-watched-page-right" class="paginate-watched btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="header-bar">
|
<div class="header-bar">
|
||||||
|
@ -175,10 +175,10 @@ DOCUMENTATION :: END
|
||||||
<div class="table-card-header">
|
<div class="table-card-header">
|
||||||
<ul class="nav nav-header nav-dashboard pull-right">
|
<ul class="nav nav-header nav-dashboard pull-right">
|
||||||
<li>
|
<li>
|
||||||
<a href="#" id="recently-added-page-left" class="paginate-added btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
<a href="#" id="recently-added-page-left" class="paginate-added btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" id="recently-added-page-right" class="paginate-added btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
<a href="#" id="recently-added-page-right" class="paginate-added btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="header-bar">
|
<div class="header-bar">
|
||||||
|
@ -690,7 +690,8 @@ DOCUMENTATION :: END
|
||||||
},
|
},
|
||||||
complete: function(xhr, status) {
|
complete: function(xhr, status) {
|
||||||
$("#library-recently-watched").html(xhr.responseText);
|
$("#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) {
|
complete: function(xhr, status) {
|
||||||
$("#library-recently-added").html(xhr.responseText);
|
$("#library-recently-added").html(xhr.responseText);
|
||||||
highlightAddedScrollerButton();
|
highlightScrollerButton("#recently-added");
|
||||||
|
paginateScroller("#recently-added", ".paginate-added");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -716,83 +718,8 @@ DOCUMENTATION :: END
|
||||||
recentlyAdded();
|
recentlyAdded();
|
||||||
% endif
|
% 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 });
|
$('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 () {
|
$(document).ready(function () {
|
||||||
|
|
||||||
// Javascript to enable link to tab
|
// Javascript to enable link to tab
|
||||||
|
|
|
@ -36,7 +36,7 @@ DOCUMENTATION :: END
|
||||||
|
|
||||||
%>
|
%>
|
||||||
<div class="dashboard-recent-media-row">
|
<div class="dashboard-recent-media-row">
|
||||||
<div id="recently-added-row-scroller" style="left: 0;">
|
<div id="recently-added-row-scroller">
|
||||||
<ul class="dashboard-recent-media list-unstyled">
|
<ul class="dashboard-recent-media list-unstyled">
|
||||||
% for item in data:
|
% for item in data:
|
||||||
<li>
|
<li>
|
||||||
|
|
|
@ -50,7 +50,10 @@
|
||||||
</div>
|
</div>
|
||||||
<p class="help-block">
|
<p class="help-block">
|
||||||
<span id="simple_cron_message">Set the schedule for the newsletter.</span>
|
<span id="simple_cron_message">Set the schedule for the newsletter.</span>
|
||||||
<span id="custom_cron_message">Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank" rel="noreferrer">custom crontab</a>. Only standard cron values are valid.</span>
|
<span id="custom_cron_message">
|
||||||
|
Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank" rel="noreferrer">custom crontab</a>.
|
||||||
|
<a href="${anon_url('https://apscheduler.readthedocs.io/en/3.x/modules/triggers/cron.html#expression-types')}" target="_blank" rel="noreferrer">Click here</a> for a list of supported expressions.
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
@ -481,7 +484,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
if (${newsletter['config']['custom_cron']}) {
|
if (${newsletter['config']['custom_cron']}) {
|
||||||
$('#cron_value').val('${newsletter['cron']}');
|
$('#cron_value').val('${newsletter['cron'] | n}');
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
cron_widget.cron('value', '${newsletter['cron']}');
|
cron_widget.cron('value', '${newsletter['cron']}');
|
||||||
|
|
|
@ -36,7 +36,7 @@ DOCUMENTATION :: END
|
||||||
%>
|
%>
|
||||||
% if data:
|
% if data:
|
||||||
<div class="dashboard-recent-media-row">
|
<div class="dashboard-recent-media-row">
|
||||||
<div id="recently-added-row-scroller" style="left: 0;">
|
<div id="recently-added-row-scroller">
|
||||||
<ul class="dashboard-recent-media list-unstyled">
|
<ul class="dashboard-recent-media list-unstyled">
|
||||||
% for item in data:
|
% for item in data:
|
||||||
<div class="dashboard-recent-media-instance">
|
<div class="dashboard-recent-media-instance">
|
||||||
|
|
|
@ -125,10 +125,10 @@ DOCUMENTATION :: END
|
||||||
<div class="table-card-header">
|
<div class="table-card-header">
|
||||||
<ul class="nav nav-header nav-dashboard pull-right">
|
<ul class="nav nav-header nav-dashboard pull-right">
|
||||||
<li>
|
<li>
|
||||||
<a href="#" id="recently-watched-page-left" class="paginate btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
<a href="#" id="recently-watched-page-left" class="paginate-watched btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" id="recently-watched-page-right" class="paginate btn-gray" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
<a href="#" id="recently-watched-page-right" class="paginate-watched btn-gray" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="header-bar">
|
<div class="header-bar">
|
||||||
|
@ -666,52 +666,14 @@ DOCUMENTATION :: END
|
||||||
},
|
},
|
||||||
complete: function(xhr, status) {
|
complete: function(xhr, status) {
|
||||||
$("#user-recently-watched").html(xhr.responseText);
|
$("#user-recently-watched").html(xhr.responseText);
|
||||||
highlightWatchedScrollerButton();
|
highlightScrollerButton("#recently-watched");
|
||||||
|
paginateScroller("#recently-watched", ".paginate-watched");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
recentlyWatched();
|
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 () {
|
$(document).ready(function () {
|
||||||
// Javascript to enable link to tab
|
// Javascript to enable link to tab
|
||||||
var hash = document.location.hash;
|
var hash = document.location.hash;
|
||||||
|
|
|
@ -31,7 +31,7 @@ DOCUMENTATION :: END
|
||||||
from plexpy.helpers import page, short_season
|
from plexpy.helpers import page, short_season
|
||||||
%>
|
%>
|
||||||
<div class="dashboard-recent-media-row">
|
<div class="dashboard-recent-media-row">
|
||||||
<div id="recently-watched-row-scroller" style="left: 0;">
|
<div id="recently-watched-row-scroller">
|
||||||
<ul class="dashboard-recent-media list-unstyled">
|
<ul class="dashboard-recent-media list-unstyled">
|
||||||
% for item in data:
|
% for item in data:
|
||||||
<li>
|
<li>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from .core import contents, where
|
from .core import contents, where
|
||||||
|
|
||||||
__all__ = ["contents", "where"]
|
__all__ = ["contents", "where"]
|
||||||
__version__ = "2024.07.04"
|
__version__ = "2024.08.30"
|
||||||
|
|
|
@ -4796,3 +4796,134 @@ PQQDAwNoADBlAjAdfKR7w4l1M+E7qUW/Runpod3JIha3RxEL2Jq68cgLcFBTApFw
|
||||||
hVmpHqTm6iMxoAACMQD94vizrxa5HnPEluPBMBnYfubDl94cT7iJLzPrSA8Z94dG
|
hVmpHqTm6iMxoAACMQD94vizrxa5HnPEluPBMBnYfubDl94cT7iJLzPrSA8Z94dG
|
||||||
XSaQpYXFuXqUPoeovQA=
|
XSaQpYXFuXqUPoeovQA=
|
||||||
-----END CERTIFICATE-----
|
-----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-----
|
||||||
|
|
|
@ -1,24 +1,34 @@
|
||||||
|
"""
|
||||||
|
APIs exposing metadata from third-party Python packages.
|
||||||
|
|
||||||
|
This codebase is shared between importlib.metadata in the stdlib
|
||||||
|
and importlib_metadata in PyPI. See
|
||||||
|
https://github.com/python/importlib_metadata/wiki/Development-Methodology
|
||||||
|
for more detail.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import abc
|
import abc
|
||||||
import sys
|
import collections
|
||||||
import json
|
|
||||||
import zipp
|
|
||||||
import email
|
import email
|
||||||
import types
|
|
||||||
import inspect
|
|
||||||
import pathlib
|
|
||||||
import operator
|
|
||||||
import textwrap
|
|
||||||
import functools
|
import functools
|
||||||
import itertools
|
import itertools
|
||||||
|
import operator
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
import posixpath
|
import posixpath
|
||||||
import collections
|
import re
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
import types
|
||||||
|
from contextlib import suppress
|
||||||
|
from importlib import import_module
|
||||||
|
from importlib.abc import MetaPathFinder
|
||||||
|
from itertools import starmap
|
||||||
|
from typing import Any, Iterable, List, Mapping, Match, Optional, Set, cast
|
||||||
|
|
||||||
from . import _meta
|
from . import _meta
|
||||||
from .compat import py39, py311
|
|
||||||
from ._collections import FreezableDefaultDict, Pair
|
from ._collections import FreezableDefaultDict, Pair
|
||||||
from ._compat import (
|
from ._compat import (
|
||||||
NullFinder,
|
NullFinder,
|
||||||
|
@ -27,12 +37,7 @@ from ._compat import (
|
||||||
from ._functools import method_cache, pass_none
|
from ._functools import method_cache, pass_none
|
||||||
from ._itertools import always_iterable, bucket, unique_everseen
|
from ._itertools import always_iterable, bucket, unique_everseen
|
||||||
from ._meta import PackageMetadata, SimplePath
|
from ._meta import PackageMetadata, SimplePath
|
||||||
|
from .compat import py39, py311
|
||||||
from contextlib import suppress
|
|
||||||
from importlib import import_module
|
|
||||||
from importlib.abc import MetaPathFinder
|
|
||||||
from itertools import starmap
|
|
||||||
from typing import Any, Iterable, List, Mapping, Match, Optional, Set, cast
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Distribution',
|
'Distribution',
|
||||||
|
@ -58,7 +63,7 @@ class PackageNotFoundError(ModuleNotFoundError):
|
||||||
return f"No package metadata was found for {self.name}"
|
return f"No package metadata was found for {self.name}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str: # type: ignore[override]
|
def name(self) -> str: # type: ignore[override] # make readonly
|
||||||
(name,) = self.args
|
(name,) = self.args
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
@ -227,9 +232,26 @@ class EntryPoint:
|
||||||
>>> ep.matches(attr='bong')
|
>>> ep.matches(attr='bong')
|
||||||
True
|
True
|
||||||
"""
|
"""
|
||||||
|
self._disallow_dist(params)
|
||||||
attrs = (getattr(self, param) for param in params)
|
attrs = (getattr(self, param) for param in params)
|
||||||
return all(map(operator.eq, params.values(), attrs))
|
return all(map(operator.eq, params.values(), attrs))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _disallow_dist(params):
|
||||||
|
"""
|
||||||
|
Querying by dist is not allowed (dist objects are not comparable).
|
||||||
|
>>> EntryPoint(name='fan', value='fav', group='fag').matches(dist='foo')
|
||||||
|
Traceback (most recent call last):
|
||||||
|
...
|
||||||
|
ValueError: "dist" is not suitable for matching...
|
||||||
|
"""
|
||||||
|
if "dist" in params:
|
||||||
|
raise ValueError(
|
||||||
|
'"dist" is not suitable for matching. '
|
||||||
|
"Instead, use Distribution.entry_points.select() on a "
|
||||||
|
"located distribution."
|
||||||
|
)
|
||||||
|
|
||||||
def _key(self):
|
def _key(self):
|
||||||
return self.name, self.value, self.group
|
return self.name, self.value, self.group
|
||||||
|
|
||||||
|
@ -259,7 +281,7 @@ class EntryPoints(tuple):
|
||||||
|
|
||||||
__slots__ = ()
|
__slots__ = ()
|
||||||
|
|
||||||
def __getitem__(self, name: str) -> EntryPoint: # type: ignore[override]
|
def __getitem__(self, name: str) -> EntryPoint: # type: ignore[override] # Work with str instead of int
|
||||||
"""
|
"""
|
||||||
Get the EntryPoint in self matching name.
|
Get the EntryPoint in self matching name.
|
||||||
"""
|
"""
|
||||||
|
@ -315,7 +337,7 @@ class PackagePath(pathlib.PurePosixPath):
|
||||||
size: int
|
size: int
|
||||||
dist: Distribution
|
dist: Distribution
|
||||||
|
|
||||||
def read_text(self, encoding: str = 'utf-8') -> str: # type: ignore[override]
|
def read_text(self, encoding: str = 'utf-8') -> str:
|
||||||
return self.locate().read_text(encoding=encoding)
|
return self.locate().read_text(encoding=encoding)
|
||||||
|
|
||||||
def read_binary(self) -> bytes:
|
def read_binary(self) -> bytes:
|
||||||
|
@ -373,6 +395,17 @@ class Distribution(metaclass=abc.ABCMeta):
|
||||||
"""
|
"""
|
||||||
Given a path to a file in this distribution, return a SimplePath
|
Given a path to a file in this distribution, return a SimplePath
|
||||||
to it.
|
to it.
|
||||||
|
|
||||||
|
This method is used by callers of ``Distribution.files()`` to
|
||||||
|
locate files within the distribution. If it's possible for a
|
||||||
|
Distribution to represent files in the distribution as
|
||||||
|
``SimplePath`` objects, it should implement this method
|
||||||
|
to resolve such objects.
|
||||||
|
|
||||||
|
Some Distribution providers may elect not to resolve SimplePath
|
||||||
|
objects within the distribution by raising a
|
||||||
|
NotImplementedError, but consumers of such a Distribution would
|
||||||
|
be unable to invoke ``Distribution.files()``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -639,6 +672,9 @@ class Distribution(metaclass=abc.ABCMeta):
|
||||||
return self._load_json('direct_url.json')
|
return self._load_json('direct_url.json')
|
||||||
|
|
||||||
def _load_json(self, filename):
|
def _load_json(self, filename):
|
||||||
|
# Deferred for performance (python/importlib_metadata#503)
|
||||||
|
import json
|
||||||
|
|
||||||
return pass_none(json.loads)(
|
return pass_none(json.loads)(
|
||||||
self.read_text(filename),
|
self.read_text(filename),
|
||||||
object_hook=lambda data: types.SimpleNamespace(**data),
|
object_hook=lambda data: types.SimpleNamespace(**data),
|
||||||
|
@ -723,7 +759,7 @@ class FastPath:
|
||||||
True
|
True
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@functools.lru_cache() # type: ignore
|
@functools.lru_cache() # type: ignore[misc]
|
||||||
def __new__(cls, root):
|
def __new__(cls, root):
|
||||||
return super().__new__(cls)
|
return super().__new__(cls)
|
||||||
|
|
||||||
|
@ -741,7 +777,10 @@ class FastPath:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def zip_children(self):
|
def zip_children(self):
|
||||||
zip_path = zipp.Path(self.root)
|
# deferred for performance (python/importlib_metadata#502)
|
||||||
|
from zipp.compat.overlay import zipfile
|
||||||
|
|
||||||
|
zip_path = zipfile.Path(self.root)
|
||||||
names = zip_path.root.namelist()
|
names = zip_path.root.namelist()
|
||||||
self.joinpath = zip_path.joinpath
|
self.joinpath = zip_path.joinpath
|
||||||
|
|
||||||
|
@ -1078,11 +1117,10 @@ def _get_toplevel_name(name: PackagePath) -> str:
|
||||||
>>> _get_toplevel_name(PackagePath('foo.dist-info'))
|
>>> _get_toplevel_name(PackagePath('foo.dist-info'))
|
||||||
'foo.dist-info'
|
'foo.dist-info'
|
||||||
"""
|
"""
|
||||||
return _topmost(name) or (
|
# Defer import of inspect for performance (python/cpython#118761)
|
||||||
# python/typeshed#10328
|
import inspect
|
||||||
inspect.getmodulename(name) # type: ignore
|
|
||||||
or str(name)
|
return _topmost(name) or inspect.getmodulename(name) or str(name)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _top_level_inferred(dist):
|
def _top_level_inferred(dist):
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import email.message
|
||||||
import re
|
import re
|
||||||
import textwrap
|
import textwrap
|
||||||
import email.message
|
|
||||||
|
|
||||||
from ._text import FoldedCase
|
from ._text import FoldedCase
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import sys
|
|
||||||
import platform
|
import platform
|
||||||
|
import sys
|
||||||
|
|
||||||
__all__ = ['install', 'NullFinder']
|
__all__ = ['install', 'NullFinder']
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import types
|
|
||||||
import functools
|
import functools
|
||||||
|
import types
|
||||||
|
|
||||||
|
|
||||||
# from jaraco.functools 3.3
|
# from jaraco.functools 3.3
|
||||||
|
|
|
@ -1,9 +1,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Protocol
|
from typing import (
|
||||||
from typing import Any, Dict, Iterator, List, Optional, TypeVar, Union, overload
|
Any,
|
||||||
|
Dict,
|
||||||
|
Iterator,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Protocol,
|
||||||
|
TypeVar,
|
||||||
|
Union,
|
||||||
|
overload,
|
||||||
|
)
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
|
@ -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 (
|
from ._common import (
|
||||||
as_file,
|
as_file,
|
||||||
|
@ -7,7 +14,7 @@ from ._common import (
|
||||||
Anchor,
|
Anchor,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .functional import (
|
from ._functional import (
|
||||||
contents,
|
contents,
|
||||||
is_resource,
|
is_resource,
|
||||||
open_binary,
|
open_binary,
|
||||||
|
|
|
@ -66,10 +66,10 @@ def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]:
|
||||||
# zipimport.zipimporter does not support weak references, resulting in a
|
# zipimport.zipimporter does not support weak references, resulting in a
|
||||||
# TypeError. That seems terrible.
|
# TypeError. That seems terrible.
|
||||||
spec = package.__spec__
|
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:
|
if reader is None:
|
||||||
return None
|
return None
|
||||||
return reader(spec.name) # type: ignore
|
return reader(spec.name) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|
||||||
@functools.singledispatch
|
@functools.singledispatch
|
||||||
|
@ -93,12 +93,13 @@ def _infer_caller():
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def is_this_file(frame_info):
|
def is_this_file(frame_info):
|
||||||
return frame_info.filename == __file__
|
return frame_info.filename == stack[0].filename
|
||||||
|
|
||||||
def is_wrapper(frame_info):
|
def is_wrapper(frame_info):
|
||||||
return frame_info.function == 'wrapper'
|
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
|
# also exclude 'wrapper' due to singledispatch in the call stack
|
||||||
callers = itertools.filterfalse(is_wrapper, not_this_file)
|
callers = itertools.filterfalse(is_wrapper, not_this_file)
|
||||||
return next(callers).frame
|
return next(callers).frame
|
||||||
|
@ -182,7 +183,7 @@ def _(path):
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def _temp_path(dir: tempfile.TemporaryDirectory):
|
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:
|
with dir as result:
|
||||||
yield pathlib.Path(result)
|
yield pathlib.Path(result)
|
||||||
|
|
|
@ -5,6 +5,6 @@ __all__ = ['ZipPath']
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 10):
|
if sys.version_info >= (3, 10):
|
||||||
from zipfile import Path as ZipPath # type: ignore
|
from zipfile import Path as ZipPath
|
||||||
else:
|
else:
|
||||||
from zipp import Path as ZipPath # type: ignore
|
from zipp import Path as ZipPath
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import contextlib
|
import contextlib
|
||||||
import itertools
|
import itertools
|
||||||
|
@ -5,6 +7,7 @@ import pathlib
|
||||||
import operator
|
import operator
|
||||||
import re
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from . import abc
|
from . import abc
|
||||||
|
|
||||||
|
@ -34,8 +37,10 @@ class FileReader(abc.TraversableResources):
|
||||||
|
|
||||||
class ZipReader(abc.TraversableResources):
|
class ZipReader(abc.TraversableResources):
|
||||||
def __init__(self, loader, module):
|
def __init__(self, loader, module):
|
||||||
|
self.prefix = loader.prefix.replace('\\', '/')
|
||||||
|
if loader.is_package(module):
|
||||||
_, _, name = module.rpartition('.')
|
_, _, name = module.rpartition('.')
|
||||||
self.prefix = loader.prefix.replace('\\', '/') + name + '/'
|
self.prefix += name + '/'
|
||||||
self.archive = loader.archive
|
self.archive = loader.archive
|
||||||
|
|
||||||
def open_resource(self, resource):
|
def open_resource(self, resource):
|
||||||
|
@ -133,27 +138,31 @@ class NamespaceReader(abc.TraversableResources):
|
||||||
def __init__(self, namespace_path):
|
def __init__(self, namespace_path):
|
||||||
if 'NamespacePath' not in str(namespace_path):
|
if 'NamespacePath' not in str(namespace_path):
|
||||||
raise ValueError('Invalid path')
|
raise ValueError('Invalid path')
|
||||||
self.path = MultiplexedPath(*map(self._resolve, namespace_path))
|
self.path = MultiplexedPath(*filter(bool, map(self._resolve, namespace_path)))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _resolve(cls, path_str) -> abc.Traversable:
|
def _resolve(cls, path_str) -> abc.Traversable | None:
|
||||||
r"""
|
r"""
|
||||||
Given an item from a namespace path, resolve it to a Traversable.
|
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
|
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
|
zipfile plus the path within the zipfile, e.g. ``/foo/bar`` or
|
||||||
``/foo/baz.zip/inner_dir`` or ``foo\baz.zip\inner_dir\sub``.
|
``/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())
|
dirs = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir())
|
||||||
return dir
|
return next(dirs, None)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _candidate_paths(cls, path_str):
|
def _candidate_paths(cls, path_str: str) -> Iterator[abc.Traversable]:
|
||||||
yield pathlib.Path(path_str)
|
yield pathlib.Path(path_str)
|
||||||
yield from cls._resolve_zip_path(path_str)
|
yield from cls._resolve_zip_path(path_str)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_zip_path(path_str):
|
def _resolve_zip_path(path_str: str):
|
||||||
for match in reversed(list(re.finditer(r'[\\/]', path_str))):
|
for match in reversed(list(re.finditer(r'[\\/]', path_str))):
|
||||||
with contextlib.suppress(
|
with contextlib.suppress(
|
||||||
FileNotFoundError,
|
FileNotFoundError,
|
||||||
|
|
|
@ -77,7 +77,7 @@ class ResourceHandle(Traversable):
|
||||||
|
|
||||||
def __init__(self, parent: ResourceContainer, name: str):
|
def __init__(self, parent: ResourceContainer, name: str):
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
self.name = name # type: ignore
|
self.name = name # type: ignore[misc]
|
||||||
|
|
||||||
def is_file(self):
|
def is_file(self):
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -2,15 +2,44 @@ import pathlib
|
||||||
import functools
|
import functools
|
||||||
|
|
||||||
from typing import Dict, Union
|
from typing import Dict, Union
|
||||||
|
from typing import runtime_checkable
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
####
|
####
|
||||||
# from jaraco.path 3.4.1
|
# from jaraco.path 3.7.1
|
||||||
|
|
||||||
FilesSpec = Dict[str, Union[str, bytes, 'FilesSpec']] # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
Build a set of files/directories, as described by the spec.
|
||||||
|
|
||||||
|
@ -25,21 +54,25 @@ def build(spec: FilesSpec, prefix=pathlib.Path()):
|
||||||
... "__init__.py": "",
|
... "__init__.py": "",
|
||||||
... },
|
... },
|
||||||
... "baz.py": "# Some code",
|
... "baz.py": "# Some code",
|
||||||
... }
|
... "bar.py": Symlink("baz.py"),
|
||||||
|
... },
|
||||||
|
... "bing": Symlink("foo"),
|
||||||
... }
|
... }
|
||||||
>>> target = getfixture('tmp_path')
|
>>> target = getfixture('tmp_path')
|
||||||
>>> build(spec, target)
|
>>> build(spec, target)
|
||||||
>>> target.joinpath('foo/baz.py').read_text(encoding='utf-8')
|
>>> target.joinpath('foo/baz.py').read_text(encoding='utf-8')
|
||||||
'# Some code'
|
'# Some code'
|
||||||
|
>>> target.joinpath('bing/bar.py').read_text(encoding='utf-8')
|
||||||
|
'# Some code'
|
||||||
"""
|
"""
|
||||||
for name, contents in spec.items():
|
for name, contents in spec.items():
|
||||||
create(contents, pathlib.Path(prefix) / name)
|
create(contents, _ensure_tree_maker(prefix) / name)
|
||||||
|
|
||||||
|
|
||||||
@functools.singledispatch
|
@functools.singledispatch
|
||||||
def create(content: Union[str, bytes, FilesSpec], path):
|
def create(content: Union[str, bytes, FilesSpec], path):
|
||||||
path.mkdir(exist_ok=True)
|
path.mkdir(exist_ok=True)
|
||||||
build(content, prefix=path) # type: ignore
|
build(content, prefix=path) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
@create.register
|
@create.register
|
||||||
|
@ -52,5 +85,10 @@ def _(content: str, path):
|
||||||
path.write_text(content, encoding='utf-8')
|
path.write_text(content, encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
@create.register
|
||||||
|
def _(content: Symlink, path):
|
||||||
|
path.symlink_to(content)
|
||||||
|
|
||||||
|
|
||||||
# end from jaraco.path
|
# end from jaraco.path
|
||||||
####
|
####
|
||||||
|
|
|
@ -8,3 +8,6 @@ import_helper = try_import('import_helper') or from_test_support(
|
||||||
'modules_setup', 'modules_cleanup', 'DirsOnSysPath'
|
'modules_setup', 'modules_cleanup', 'DirsOnSysPath'
|
||||||
)
|
)
|
||||||
os_helper = try_import('os_helper') or from_test_support('temp_dir')
|
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'
|
||||||
|
)
|
||||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -1 +0,0 @@
|
||||||
Hello, UTF-8 world!
|
|
|
@ -1 +0,0 @@
|
||||||
one resource
|
|
|
@ -1 +0,0 @@
|
||||||
a resource
|
|
|
@ -1 +0,0 @@
|
||||||
two resource
|
|
Binary file not shown.
Binary file not shown.
|
@ -1 +0,0 @@
|
||||||
Hello, UTF-8 world!
|
|
|
@ -1,7 +1,6 @@
|
||||||
import unittest
|
import unittest
|
||||||
import importlib_resources as resources
|
import importlib_resources as resources
|
||||||
|
|
||||||
from . import data01
|
|
||||||
from . import util
|
from . import util
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,16 +18,17 @@ class ContentsTests:
|
||||||
assert self.expected <= contents
|
assert self.expected <= contents
|
||||||
|
|
||||||
|
|
||||||
class ContentsDiskTests(ContentsTests, unittest.TestCase):
|
class ContentsDiskTests(ContentsTests, util.DiskSetup, unittest.TestCase):
|
||||||
def setUp(self):
|
pass
|
||||||
self.data = data01
|
|
||||||
|
|
||||||
|
|
||||||
class ContentsZipTests(ContentsTests, util.ZipSetup, unittest.TestCase):
|
class ContentsZipTests(ContentsTests, util.ZipSetup, unittest.TestCase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ContentsNamespaceTests(ContentsTests, unittest.TestCase):
|
class ContentsNamespaceTests(ContentsTests, util.DiskSetup, unittest.TestCase):
|
||||||
|
MODULE = 'namespacedata01'
|
||||||
|
|
||||||
expected = {
|
expected = {
|
||||||
# no __init__ because of namespace design
|
# no __init__ because of namespace design
|
||||||
'binary.file',
|
'binary.file',
|
||||||
|
@ -36,8 +36,3 @@ class ContentsNamespaceTests(ContentsTests, unittest.TestCase):
|
||||||
'utf-16.file',
|
'utf-16.file',
|
||||||
'utf-8.file',
|
'utf-8.file',
|
||||||
}
|
}
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
from . import namespacedata01
|
|
||||||
|
|
||||||
self.data = namespacedata01
|
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import py_compile
|
||||||
|
import shutil
|
||||||
import textwrap
|
import textwrap
|
||||||
import unittest
|
import unittest
|
||||||
import warnings
|
import warnings
|
||||||
|
@ -6,11 +10,8 @@ import contextlib
|
||||||
|
|
||||||
import importlib_resources as resources
|
import importlib_resources as resources
|
||||||
from ..abc import Traversable
|
from ..abc import Traversable
|
||||||
from . import data01
|
|
||||||
from . import util
|
from . import util
|
||||||
from . import _path
|
from .compat.py39 import os_helper, import_helper
|
||||||
from .compat.py39 import os_helper
|
|
||||||
from .compat.py312 import import_helper
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
|
@ -48,70 +49,146 @@ class FilesTests:
|
||||||
resources.files(package=self.data)
|
resources.files(package=self.data)
|
||||||
|
|
||||||
|
|
||||||
class OpenDiskTests(FilesTests, unittest.TestCase):
|
class OpenDiskTests(FilesTests, util.DiskSetup, unittest.TestCase):
|
||||||
def setUp(self):
|
pass
|
||||||
self.data = data01
|
|
||||||
|
|
||||||
|
|
||||||
class OpenZipTests(FilesTests, util.ZipSetup, unittest.TestCase):
|
class OpenZipTests(FilesTests, util.ZipSetup, unittest.TestCase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class OpenNamespaceTests(FilesTests, unittest.TestCase):
|
class OpenNamespaceTests(FilesTests, util.DiskSetup, unittest.TestCase):
|
||||||
def setUp(self):
|
MODULE = 'namespacedata01'
|
||||||
from . import 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):
|
class OpenNamespaceZipTests(FilesTests, util.ZipSetup, unittest.TestCase):
|
||||||
ZIP_MODULE = 'namespacedata01'
|
ZIP_MODULE = 'namespacedata01'
|
||||||
|
|
||||||
|
|
||||||
class SiteDir:
|
class DirectSpec:
|
||||||
def setUp(self):
|
"""
|
||||||
self.fixtures = contextlib.ExitStack()
|
Override behavior of ModuleSetup to write a full spec directly.
|
||||||
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))
|
MODULE = 'unused'
|
||||||
self.fixtures.enter_context(import_helper.isolated_modules())
|
|
||||||
|
def load_fixture(self, name):
|
||||||
|
self.tree_on_path(self.spec)
|
||||||
|
|
||||||
|
|
||||||
class ModulesFilesTests(SiteDir, unittest.TestCase):
|
class ModulesFiles:
|
||||||
def test_module_resources(self):
|
|
||||||
"""
|
|
||||||
A module can have resources found adjacent to the module.
|
|
||||||
"""
|
|
||||||
spec = {
|
spec = {
|
||||||
'mod.py': '',
|
'mod.py': '',
|
||||||
'res.txt': 'resources are the best',
|
'res.txt': 'resources are the best',
|
||||||
}
|
}
|
||||||
_path.build(spec, self.site_dir)
|
|
||||||
import mod
|
def test_module_resources(self):
|
||||||
|
"""
|
||||||
|
A module can have resources found adjacent to the module.
|
||||||
|
"""
|
||||||
|
import mod # type: ignore[import-not-found]
|
||||||
|
|
||||||
actual = resources.files(mod).joinpath('res.txt').read_text(encoding='utf-8')
|
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):
|
class ModuleFilesDiskTests(DirectSpec, util.DiskSetup, ModulesFiles, unittest.TestCase):
|
||||||
def test_implicit_files(self):
|
pass
|
||||||
"""
|
|
||||||
Without any parameter, files() will infer the location as the caller.
|
|
||||||
"""
|
class ModuleFilesZipTests(DirectSpec, util.ZipSetup, ModulesFiles, unittest.TestCase):
|
||||||
spec = {
|
pass
|
||||||
'somepkg': {
|
|
||||||
'__init__.py': textwrap.dedent(
|
|
||||||
"""
|
class ImplicitContextFiles:
|
||||||
import importlib_resources as res
|
set_val = textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
import {resources.__name__} as res
|
||||||
val = res.files().joinpath('res.txt').read_text(encoding='utf-8')
|
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',
|
'res.txt': 'resources are the best',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_path.build(spec, self.site_dir)
|
|
||||||
|
def test_implicit_files_package(self):
|
||||||
|
"""
|
||||||
|
Without any parameter, files() will infer the location as the caller.
|
||||||
|
"""
|
||||||
assert importlib.import_module('somepkg').val == 'resources are the best'
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
@ -1,31 +1,38 @@
|
||||||
import unittest
|
import unittest
|
||||||
import os
|
import os
|
||||||
import contextlib
|
import importlib
|
||||||
|
|
||||||
try:
|
from .compat.py39 import warnings_helper
|
||||||
from test.support.warnings_helper import ignore_warnings, check_warnings
|
|
||||||
except ImportError:
|
|
||||||
# older Python versions
|
|
||||||
from test.support import ignore_warnings, check_warnings
|
|
||||||
|
|
||||||
import importlib_resources as resources
|
import importlib_resources as resources
|
||||||
|
|
||||||
|
from . import util
|
||||||
|
|
||||||
# Since the functional API forwards to Traversable, we only test
|
# Since the functional API forwards to Traversable, we only test
|
||||||
# filesystem resources here -- not zip files, namespace packages etc.
|
# filesystem resources here -- not zip files, namespace packages etc.
|
||||||
# We do test for two kinds of Anchor, though.
|
# We do test for two kinds of Anchor, though.
|
||||||
|
|
||||||
|
|
||||||
class StringAnchorMixin:
|
class StringAnchorMixin:
|
||||||
anchor01 = 'importlib_resources.tests.data01'
|
anchor01 = 'data01'
|
||||||
anchor02 = 'importlib_resources.tests.data02'
|
anchor02 = 'data02'
|
||||||
|
|
||||||
|
|
||||||
class ModuleAnchorMixin:
|
class ModuleAnchorMixin:
|
||||||
from . import data01 as anchor01
|
@property
|
||||||
from . import data02 as anchor02
|
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):
|
def _gen_resourcetxt_path_parts(self):
|
||||||
"""Yield various names of a text file in anchor02, each in a subTest"""
|
"""Yield various names of a text file in anchor02, each in a subTest"""
|
||||||
for path_parts in (
|
for path_parts in (
|
||||||
|
@ -36,6 +43,12 @@ class FunctionalAPIBase:
|
||||||
with self.subTest(path_parts=path_parts):
|
with self.subTest(path_parts=path_parts):
|
||||||
yield 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):
|
def test_read_text(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
resources.read_text(self.anchor01, 'utf-8.file'),
|
resources.read_text(self.anchor01, 'utf-8.file'),
|
||||||
|
@ -76,13 +89,13 @@ class FunctionalAPIBase:
|
||||||
),
|
),
|
||||||
'\x00\x01\x02\x03',
|
'\x00\x01\x02\x03',
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEndsWith( # ignore the BOM
|
||||||
resources.read_text(
|
resources.read_text(
|
||||||
self.anchor01,
|
self.anchor01,
|
||||||
'utf-16.file',
|
'utf-16.file',
|
||||||
errors='backslashreplace',
|
errors='backslashreplace',
|
||||||
),
|
),
|
||||||
'Hello, UTF-16 world!\n'.encode('utf-16').decode(
|
'Hello, UTF-16 world!\n'.encode('utf-16-le').decode(
|
||||||
errors='backslashreplace',
|
errors='backslashreplace',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -128,9 +141,9 @@ class FunctionalAPIBase:
|
||||||
'utf-16.file',
|
'utf-16.file',
|
||||||
errors='backslashreplace',
|
errors='backslashreplace',
|
||||||
) as f:
|
) as f:
|
||||||
self.assertEqual(
|
self.assertEndsWith( # ignore the BOM
|
||||||
f.read(),
|
f.read(),
|
||||||
'Hello, UTF-16 world!\n'.encode('utf-16').decode(
|
'Hello, UTF-16 world!\n'.encode('utf-16-le').decode(
|
||||||
errors='backslashreplace',
|
errors='backslashreplace',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -163,32 +176,32 @@ class FunctionalAPIBase:
|
||||||
self.assertTrue(is_resource(self.anchor02, *path_parts))
|
self.assertTrue(is_resource(self.anchor02, *path_parts))
|
||||||
|
|
||||||
def test_contents(self):
|
def test_contents(self):
|
||||||
with check_warnings((".*contents.*", DeprecationWarning)):
|
with warnings_helper.check_warnings((".*contents.*", DeprecationWarning)):
|
||||||
c = resources.contents(self.anchor01)
|
c = resources.contents(self.anchor01)
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(c),
|
set(c),
|
||||||
{'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'},
|
{'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'},
|
||||||
)
|
)
|
||||||
with contextlib.ExitStack() as cm:
|
with self.assertRaises(OSError), warnings_helper.check_warnings((
|
||||||
cm.enter_context(self.assertRaises(OSError))
|
".*contents.*",
|
||||||
cm.enter_context(check_warnings((".*contents.*", DeprecationWarning)))
|
DeprecationWarning,
|
||||||
|
)):
|
||||||
list(resources.contents(self.anchor01, 'utf-8.file'))
|
list(resources.contents(self.anchor01, 'utf-8.file'))
|
||||||
|
|
||||||
for path_parts in self._gen_resourcetxt_path_parts():
|
for path_parts in self._gen_resourcetxt_path_parts():
|
||||||
with contextlib.ExitStack() as cm:
|
with self.assertRaises(OSError), warnings_helper.check_warnings((
|
||||||
cm.enter_context(self.assertRaises(OSError))
|
".*contents.*",
|
||||||
cm.enter_context(check_warnings((".*contents.*", DeprecationWarning)))
|
DeprecationWarning,
|
||||||
|
)):
|
||||||
list(resources.contents(self.anchor01, *path_parts))
|
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')
|
c = resources.contents(self.anchor01, 'subdirectory')
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(c),
|
set(c),
|
||||||
{'binary.file'},
|
{'binary.file'},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ignore_warnings(category=DeprecationWarning)
|
@warnings_helper.ignore_warnings(category=DeprecationWarning)
|
||||||
def test_common_errors(self):
|
def test_common_errors(self):
|
||||||
for func in (
|
for func in (
|
||||||
resources.read_text,
|
resources.read_text,
|
||||||
|
@ -227,16 +240,16 @@ class FunctionalAPIBase:
|
||||||
|
|
||||||
|
|
||||||
class FunctionalAPITest_StringAnchor(
|
class FunctionalAPITest_StringAnchor(
|
||||||
unittest.TestCase,
|
|
||||||
FunctionalAPIBase,
|
|
||||||
StringAnchorMixin,
|
StringAnchorMixin,
|
||||||
|
FunctionalAPIBase,
|
||||||
|
unittest.TestCase,
|
||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FunctionalAPITest_ModuleAnchor(
|
class FunctionalAPITest_ModuleAnchor(
|
||||||
unittest.TestCase,
|
|
||||||
FunctionalAPIBase,
|
|
||||||
ModuleAnchorMixin,
|
ModuleAnchorMixin,
|
||||||
|
FunctionalAPIBase,
|
||||||
|
unittest.TestCase,
|
||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import importlib_resources as resources
|
import importlib_resources as resources
|
||||||
from . import data01
|
|
||||||
from . import util
|
from . import util
|
||||||
|
|
||||||
|
|
||||||
|
@ -65,16 +64,12 @@ class OpenTests:
|
||||||
target.open(encoding='utf-8')
|
target.open(encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
class OpenDiskTests(OpenTests, unittest.TestCase):
|
class OpenDiskTests(OpenTests, util.DiskSetup, unittest.TestCase):
|
||||||
def setUp(self):
|
pass
|
||||||
self.data = data01
|
|
||||||
|
|
||||||
|
|
||||||
class OpenDiskNamespaceTests(OpenTests, unittest.TestCase):
|
class OpenDiskNamespaceTests(OpenTests, util.DiskSetup, unittest.TestCase):
|
||||||
def setUp(self):
|
MODULE = 'namespacedata01'
|
||||||
from . import namespacedata01
|
|
||||||
|
|
||||||
self.data = namespacedata01
|
|
||||||
|
|
||||||
|
|
||||||
class OpenZipTests(OpenTests, util.ZipSetup, unittest.TestCase):
|
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):
|
class OpenNamespaceZipTests(OpenTests, util.ZipSetup, unittest.TestCase):
|
||||||
ZIP_MODULE = 'namespacedata01'
|
MODULE = 'namespacedata01'
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
@ -3,7 +3,6 @@ import pathlib
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import importlib_resources as resources
|
import importlib_resources as resources
|
||||||
from . import data01
|
|
||||||
from . import util
|
from . import util
|
||||||
|
|
||||||
|
|
||||||
|
@ -25,9 +24,7 @@ class PathTests:
|
||||||
self.assertEqual('Hello, UTF-8 world!\n', path.read_text(encoding='utf-8'))
|
self.assertEqual('Hello, UTF-8 world!\n', path.read_text(encoding='utf-8'))
|
||||||
|
|
||||||
|
|
||||||
class PathDiskTests(PathTests, unittest.TestCase):
|
class PathDiskTests(PathTests, util.DiskSetup, unittest.TestCase):
|
||||||
data = data01
|
|
||||||
|
|
||||||
def test_natural_path(self):
|
def test_natural_path(self):
|
||||||
"""
|
"""
|
||||||
Guarantee the internal implementation detail that
|
Guarantee the internal implementation detail that
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import unittest
|
import unittest
|
||||||
import importlib_resources as resources
|
import importlib_resources as resources
|
||||||
|
|
||||||
from . import data01
|
|
||||||
from . import util
|
from . import util
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
|
@ -52,8 +51,8 @@ class ReadTests:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReadDiskTests(ReadTests, unittest.TestCase):
|
class ReadDiskTests(ReadTests, util.DiskSetup, unittest.TestCase):
|
||||||
data = data01
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ReadZipTests(ReadTests, util.ZipSetup, unittest.TestCase):
|
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)))
|
self.assertEqual(result, bytes(range(4, 8)))
|
||||||
|
|
||||||
|
|
||||||
class ReadNamespaceTests(ReadTests, unittest.TestCase):
|
class ReadNamespaceTests(ReadTests, util.DiskSetup, unittest.TestCase):
|
||||||
def setUp(self):
|
MODULE = 'namespacedata01'
|
||||||
from . import namespacedata01
|
|
||||||
|
|
||||||
self.data = namespacedata01
|
|
||||||
|
|
||||||
|
|
||||||
class ReadNamespaceZipTests(ReadTests, util.ZipSetup, unittest.TestCase):
|
class ReadNamespaceZipTests(ReadTests, util.ZipSetup, unittest.TestCase):
|
||||||
ZIP_MODULE = 'namespacedata01'
|
MODULE = 'namespacedata01'
|
||||||
|
|
||||||
def test_read_submodule_resource(self):
|
def test_read_submodule_resource(self):
|
||||||
submodule = import_module('namespacedata01.subdirectory')
|
submodule = import_module('namespacedata01.subdirectory')
|
||||||
|
|
|
@ -1,16 +1,21 @@
|
||||||
import os.path
|
import os.path
|
||||||
import sys
|
|
||||||
import pathlib
|
import pathlib
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from importlib_resources.readers import MultiplexedPath, NamespaceReader
|
from importlib_resources.readers import MultiplexedPath, NamespaceReader
|
||||||
|
|
||||||
|
from . import util
|
||||||
|
|
||||||
class MultiplexedPathTest(unittest.TestCase):
|
|
||||||
@classmethod
|
class MultiplexedPathTest(util.DiskSetup, unittest.TestCase):
|
||||||
def setUpClass(cls):
|
MODULE = 'namespacedata01'
|
||||||
cls.folder = pathlib.Path(__file__).parent / '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):
|
def test_init_no_paths(self):
|
||||||
with self.assertRaises(FileNotFoundError):
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
@ -31,9 +36,8 @@ class MultiplexedPathTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_iterdir_duplicate(self):
|
def test_iterdir_duplicate(self):
|
||||||
data01 = pathlib.Path(__file__).parent.joinpath('data01')
|
|
||||||
contents = {
|
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'):
|
for remove in ('__pycache__', '__init__.pyc'):
|
||||||
try:
|
try:
|
||||||
|
@ -61,9 +65,8 @@ class MultiplexedPathTest(unittest.TestCase):
|
||||||
path.open()
|
path.open()
|
||||||
|
|
||||||
def test_join_path(self):
|
def test_join_path(self):
|
||||||
data01 = pathlib.Path(__file__).parent.joinpath('data01')
|
prefix = str(self.folder.parent)
|
||||||
prefix = str(data01.parent)
|
path = MultiplexedPath(self.folder, self.data01)
|
||||||
path = MultiplexedPath(self.folder, data01)
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
str(path.joinpath('binary.file'))[len(prefix) + 1 :],
|
str(path.joinpath('binary.file'))[len(prefix) + 1 :],
|
||||||
os.path.join('namespacedata01', 'binary.file'),
|
os.path.join('namespacedata01', 'binary.file'),
|
||||||
|
@ -83,10 +86,8 @@ class MultiplexedPathTest(unittest.TestCase):
|
||||||
assert not path.joinpath('imaginary/foo.py').exists()
|
assert not path.joinpath('imaginary/foo.py').exists()
|
||||||
|
|
||||||
def test_join_path_common_subdir(self):
|
def test_join_path_common_subdir(self):
|
||||||
data01 = pathlib.Path(__file__).parent.joinpath('data01')
|
prefix = str(self.data02.parent)
|
||||||
data02 = pathlib.Path(__file__).parent.joinpath('data02')
|
path = MultiplexedPath(self.data01, self.data02)
|
||||||
prefix = str(data01.parent)
|
|
||||||
path = MultiplexedPath(data01, data02)
|
|
||||||
self.assertIsInstance(path.joinpath('subdirectory'), MultiplexedPath)
|
self.assertIsInstance(path.joinpath('subdirectory'), MultiplexedPath)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
str(path.joinpath('subdirectory', 'subsubdir'))[len(prefix) + 1 :],
|
str(path.joinpath('subdirectory', 'subsubdir'))[len(prefix) + 1 :],
|
||||||
|
@ -106,16 +107,8 @@ class MultiplexedPathTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NamespaceReaderTest(unittest.TestCase):
|
class NamespaceReaderTest(util.DiskSetup, unittest.TestCase):
|
||||||
site_dir = str(pathlib.Path(__file__).parent)
|
MODULE = 'namespacedata01'
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
sys.path.append(cls.site_dir)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def tearDownClass(cls):
|
|
||||||
sys.path.remove(cls.site_dir)
|
|
||||||
|
|
||||||
def test_init_error(self):
|
def test_init_error(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
|
@ -125,7 +118,7 @@ class NamespaceReaderTest(unittest.TestCase):
|
||||||
namespacedata01 = import_module('namespacedata01')
|
namespacedata01 = import_module('namespacedata01')
|
||||||
reader = NamespaceReader(namespacedata01.__spec__.submodule_search_locations)
|
reader = NamespaceReader(namespacedata01.__spec__.submodule_search_locations)
|
||||||
|
|
||||||
root = os.path.abspath(os.path.join(__file__, '..', 'namespacedata01'))
|
root = self.data.__path__[0]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
reader.resource_path('binary.file'), os.path.join(root, 'binary.file')
|
reader.resource_path('binary.file'), os.path.join(root, 'binary.file')
|
||||||
)
|
)
|
||||||
|
@ -134,9 +127,8 @@ class NamespaceReaderTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_files(self):
|
def test_files(self):
|
||||||
namespacedata01 = import_module('namespacedata01')
|
reader = NamespaceReader(self.data.__spec__.submodule_search_locations)
|
||||||
reader = NamespaceReader(namespacedata01.__spec__.submodule_search_locations)
|
root = self.data.__path__[0]
|
||||||
root = os.path.abspath(os.path.join(__file__, '..', 'namespacedata01'))
|
|
||||||
self.assertIsInstance(reader.files(), MultiplexedPath)
|
self.assertIsInstance(reader.files(), MultiplexedPath)
|
||||||
self.assertEqual(repr(reader.files()), f"MultiplexedPath('{root}')")
|
self.assertEqual(repr(reader.files()), f"MultiplexedPath('{root}')")
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
import sys
|
|
||||||
import unittest
|
import unittest
|
||||||
import importlib_resources as resources
|
import importlib_resources as resources
|
||||||
import pathlib
|
|
||||||
|
|
||||||
from . import data01
|
|
||||||
from . import util
|
from . import util
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
|
@ -25,9 +22,8 @@ class ResourceTests:
|
||||||
self.assertTrue(target.is_dir())
|
self.assertTrue(target.is_dir())
|
||||||
|
|
||||||
|
|
||||||
class ResourceDiskTests(ResourceTests, unittest.TestCase):
|
class ResourceDiskTests(ResourceTests, util.DiskSetup, unittest.TestCase):
|
||||||
def setUp(self):
|
pass
|
||||||
self.data = data01
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceZipTests(ResourceTests, util.ZipSetup, unittest.TestCase):
|
class ResourceZipTests(ResourceTests, util.ZipSetup, unittest.TestCase):
|
||||||
|
@ -38,33 +34,39 @@ def names(traversable):
|
||||||
return {item.name for item in traversable.iterdir()}
|
return {item.name for item in traversable.iterdir()}
|
||||||
|
|
||||||
|
|
||||||
class ResourceLoaderTests(unittest.TestCase):
|
class ResourceLoaderTests(util.DiskSetup, unittest.TestCase):
|
||||||
def test_resource_contents(self):
|
def test_resource_contents(self):
|
||||||
package = util.create_package(
|
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'})
|
self.assertEqual(names(resources.files(package)), {'A', 'B', 'C'})
|
||||||
|
|
||||||
def test_is_file(self):
|
def test_is_file(self):
|
||||||
package = util.create_package(
|
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())
|
self.assertTrue(resources.files(package).joinpath('B').is_file())
|
||||||
|
|
||||||
def test_is_dir(self):
|
def test_is_dir(self):
|
||||||
package = util.create_package(
|
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())
|
self.assertTrue(resources.files(package).joinpath('D').is_dir())
|
||||||
|
|
||||||
def test_resource_missing(self):
|
def test_resource_missing(self):
|
||||||
package = util.create_package(
|
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())
|
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):
|
def test_package_has_no_reader_fallback(self):
|
||||||
"""
|
"""
|
||||||
Test odd ball packages which:
|
Test odd ball packages which:
|
||||||
|
@ -73,7 +75,7 @@ class ResourceCornerCaseTests(unittest.TestCase):
|
||||||
# 3. Are not in a zip file
|
# 3. Are not in a zip file
|
||||||
"""
|
"""
|
||||||
module = util.create_package(
|
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.
|
# Give the module a dummy loader.
|
||||||
module.__loader__ = object()
|
module.__loader__ = object()
|
||||||
|
@ -84,9 +86,7 @@ class ResourceCornerCaseTests(unittest.TestCase):
|
||||||
self.assertFalse(resources.files(module).joinpath('A').is_file())
|
self.assertFalse(resources.files(module).joinpath('A').is_file())
|
||||||
|
|
||||||
|
|
||||||
class ResourceFromZipsTest01(util.ZipSetupBase, unittest.TestCase):
|
class ResourceFromZipsTest01(util.ZipSetup, unittest.TestCase):
|
||||||
ZIP_MODULE = 'data01'
|
|
||||||
|
|
||||||
def test_is_submodule_resource(self):
|
def test_is_submodule_resource(self):
|
||||||
submodule = import_module('data01.subdirectory')
|
submodule = import_module('data01.subdirectory')
|
||||||
self.assertTrue(resources.files(submodule).joinpath('binary.file').is_file())
|
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()
|
assert not data.parent.exists()
|
||||||
|
|
||||||
|
|
||||||
class ResourceFromZipsTest02(util.ZipSetupBase, unittest.TestCase):
|
class ResourceFromZipsTest02(util.ZipSetup, unittest.TestCase):
|
||||||
ZIP_MODULE = 'data02'
|
MODULE = 'data02'
|
||||||
|
|
||||||
def test_unrelated_contents(self):
|
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
|
"""Having accessed resources in a zip file should not keep an open
|
||||||
reference to the zip.
|
reference to the zip.
|
||||||
"""
|
"""
|
||||||
|
@ -217,24 +217,20 @@ class ResourceFromNamespaceTests:
|
||||||
self.assertEqual(contents, {'binary.file'})
|
self.assertEqual(contents, {'binary.file'})
|
||||||
|
|
||||||
|
|
||||||
class ResourceFromNamespaceDiskTests(ResourceFromNamespaceTests, unittest.TestCase):
|
class ResourceFromNamespaceDiskTests(
|
||||||
site_dir = str(pathlib.Path(__file__).parent)
|
util.DiskSetup,
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
sys.path.append(cls.site_dir)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def tearDownClass(cls):
|
|
||||||
sys.path.remove(cls.site_dir)
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceFromNamespaceZipTests(
|
|
||||||
util.ZipSetupBase,
|
|
||||||
ResourceFromNamespaceTests,
|
ResourceFromNamespaceTests,
|
||||||
unittest.TestCase,
|
unittest.TestCase,
|
||||||
):
|
):
|
||||||
ZIP_MODULE = 'namespacedata01'
|
MODULE = 'namespacedata01'
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceFromNamespaceZipTests(
|
||||||
|
util.ZipSetup,
|
||||||
|
ResourceFromNamespaceTests,
|
||||||
|
unittest.TestCase,
|
||||||
|
):
|
||||||
|
MODULE = 'namespacedata01'
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
@ -6,10 +6,10 @@ import types
|
||||||
import pathlib
|
import pathlib
|
||||||
import contextlib
|
import contextlib
|
||||||
|
|
||||||
from . import data01
|
|
||||||
from ..abc import ResourceReader
|
from ..abc import ResourceReader
|
||||||
from .compat.py39 import import_helper, os_helper
|
from .compat.py39 import import_helper, os_helper
|
||||||
from . import zip as zip_
|
from . import zip as zip_
|
||||||
|
from . import _path
|
||||||
|
|
||||||
|
|
||||||
from importlib.machinery import ModuleSpec
|
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.
|
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.
|
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):
|
def test_package_object(self):
|
||||||
"""
|
"""
|
||||||
Passing in the package itself should succeed.
|
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):
|
def test_string_path(self):
|
||||||
"""
|
"""
|
||||||
Passing in a string for the path should succeed.
|
Passing in a string for the path should succeed.
|
||||||
"""
|
"""
|
||||||
path = 'utf-8.file'
|
path = 'utf-8.file'
|
||||||
self.execute(data01, path)
|
self.execute(self.data, path)
|
||||||
|
|
||||||
def test_pathlib_path(self):
|
def test_pathlib_path(self):
|
||||||
"""
|
"""
|
||||||
Passing in a pathlib.PurePath object for the path should succeed.
|
Passing in a pathlib.PurePath object for the path should succeed.
|
||||||
"""
|
"""
|
||||||
path = pathlib.PurePath('utf-8.file')
|
path = pathlib.PurePath('utf-8.file')
|
||||||
self.execute(data01, path)
|
self.execute(self.data, path)
|
||||||
|
|
||||||
def test_importing_module_as_side_effect(self):
|
def test_importing_module_as_side_effect(self):
|
||||||
"""
|
"""
|
||||||
The anchor package can already be imported.
|
The anchor package can already be imported.
|
||||||
"""
|
"""
|
||||||
del sys.modules[data01.__name__]
|
del sys.modules[self.data.__name__]
|
||||||
self.execute(data01.__name__, 'utf-8.file')
|
self.execute(self.data.__name__, 'utf-8.file')
|
||||||
|
|
||||||
def test_missing_path(self):
|
def test_missing_path(self):
|
||||||
"""
|
"""
|
||||||
|
@ -141,24 +141,66 @@ class CommonTests(metaclass=abc.ABCMeta):
|
||||||
self.execute(package, 'utf-8.file')
|
self.execute(package, 'utf-8.file')
|
||||||
|
|
||||||
|
|
||||||
class ZipSetupBase:
|
fixtures = dict(
|
||||||
ZIP_MODULE = 'data01'
|
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):
|
def setUp(self):
|
||||||
self.fixtures = contextlib.ExitStack()
|
self.fixtures = contextlib.ExitStack()
|
||||||
self.addCleanup(self.fixtures.close)
|
self.addCleanup(self.fixtures.close)
|
||||||
|
|
||||||
self.fixtures.enter_context(import_helper.isolated_modules())
|
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())
|
temp_dir = self.fixtures.enter_context(os_helper.temp_dir())
|
||||||
modules = pathlib.Path(temp_dir) / 'zipped modules.zip'
|
modules = pathlib.Path(temp_dir) / 'zipped modules.zip'
|
||||||
src_path = pathlib.Path(__file__).parent.joinpath(self.ZIP_MODULE)
|
|
||||||
self.fixtures.enter_context(
|
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
|
pass
|
||||||
|
|
|
@ -2,31 +2,25 @@
|
||||||
Generate zip test data files.
|
Generate zip test data files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
import zipp
|
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:
|
with zipfile.ZipFile(dst, 'w') as zf:
|
||||||
for src_path, rel in walk(src):
|
for name, contents in walk(tree):
|
||||||
dst_name = src.name / pathlib.PurePosixPath(rel.as_posix())
|
zf.writestr(name, contents)
|
||||||
zf.write(src_path, dst_name)
|
|
||||||
zipp.CompleteDirs.inject(zf)
|
zipp.CompleteDirs.inject(zf)
|
||||||
return dst
|
return dst
|
||||||
|
|
||||||
|
|
||||||
def walk(datapath):
|
def walk(tree, prefix=''):
|
||||||
for dirpath, dirnames, filenames in os.walk(datapath):
|
for name, contents in tree.items():
|
||||||
with contextlib.suppress(ValueError):
|
if isinstance(contents, dict):
|
||||||
dirnames.remove('__pycache__')
|
yield from walk(contents, prefix=f'{prefix}{name}/')
|
||||||
for filename in filenames:
|
else:
|
||||||
res = pathlib.Path(dirpath) / filename
|
yield f'{prefix}{name}', contents
|
||||||
rel = res.relative_to(datapath)
|
|
||||||
yield res, rel
|
|
||||||
|
|
|
@ -193,6 +193,7 @@ class Artist(
|
||||||
similar (List<:class:`~plexapi.media.Similar`>): List of similar objects.
|
similar (List<:class:`~plexapi.media.Similar`>): List of similar objects.
|
||||||
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||||
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
||||||
|
ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object.
|
||||||
"""
|
"""
|
||||||
TAG = 'Directory'
|
TAG = 'Directory'
|
||||||
TYPE = 'artist'
|
TYPE = 'artist'
|
||||||
|
@ -213,6 +214,7 @@ class Artist(
|
||||||
self.similar = self.findItems(data, media.Similar)
|
self.similar = self.findItems(data, media.Similar)
|
||||||
self.styles = self.findItems(data, media.Style)
|
self.styles = self.findItems(data, media.Style)
|
||||||
self.theme = data.attrib.get('theme')
|
self.theme = data.attrib.get('theme')
|
||||||
|
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
for album in self.albums():
|
for album in self.albums():
|
||||||
|
@ -281,6 +283,21 @@ class Artist(
|
||||||
filepaths += track.download(_savepath, keep_original_name, **kwargs)
|
filepaths += track.download(_savepath, keep_original_name, **kwargs)
|
||||||
return filepaths
|
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):
|
def station(self):
|
||||||
""" Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """
|
""" Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """
|
||||||
key = f'{self.key}?includeStations=1'
|
key = f'{self.key}?includeStations=1'
|
||||||
|
@ -325,6 +342,7 @@ class Album(
|
||||||
studio (str): Studio that released the album.
|
studio (str): Studio that released the album.
|
||||||
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||||
subformats (List<:class:`~plexapi.media.Subformat`>): List of subformat 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.
|
viewedLeafCount (int): Number of items marked as played in the album view.
|
||||||
year (int): Year the album was released.
|
year (int): Year the album was released.
|
||||||
"""
|
"""
|
||||||
|
@ -354,6 +372,7 @@ class Album(
|
||||||
self.studio = data.attrib.get('studio')
|
self.studio = data.attrib.get('studio')
|
||||||
self.styles = self.findItems(data, media.Style)
|
self.styles = self.findItems(data, media.Style)
|
||||||
self.subformats = self.findItems(data, media.Subformat)
|
self.subformats = self.findItems(data, media.Subformat)
|
||||||
|
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
|
||||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
self.year = utils.cast(int, data.attrib.get('year'))
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import re
|
||||||
from typing import TYPE_CHECKING, Generic, Iterable, List, Optional, TypeVar, Union
|
from typing import TYPE_CHECKING, Generic, Iterable, List, Optional, TypeVar, Union
|
||||||
import weakref
|
import weakref
|
||||||
from functools import cached_property
|
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 import ElementTree
|
||||||
from xml.etree.ElementTree import Element
|
from xml.etree.ElementTree import Element
|
||||||
|
|
||||||
|
@ -391,10 +391,9 @@ class PlexObject:
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
key (string, optional): Override the key to reload.
|
key (string, optional): Override the key to reload.
|
||||||
**kwargs (dict): A dictionary of XML include parameters to exclude or override.
|
**kwargs (dict): A dictionary of XML include parameters to include/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.
|
|
||||||
See :class:`~plexapi.base.PlexPartialObject` for all the available include parameters.
|
See :class:`~plexapi.base.PlexPartialObject` for all the available include parameters.
|
||||||
|
Set parameter to True to include and False to exclude.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
@ -402,20 +401,28 @@ class PlexObject:
|
||||||
|
|
||||||
from plexapi.server import PlexServer
|
from plexapi.server import PlexServer
|
||||||
plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx')
|
plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx')
|
||||||
movie = plex.library.section('Movies').get('Cars')
|
|
||||||
|
|
||||||
# Partial reload of the movie without the `checkFiles` parameter.
|
# Search results are partial objects.
|
||||||
# Excluding `checkFiles` will prevent the Plex server from reading the
|
movie = plex.library.section('Movies').get('Cars')
|
||||||
# file to check if the file still exists and is accessible.
|
|
||||||
# The movie object will remain as a partial object.
|
|
||||||
movie.reload(checkFiles=False)
|
|
||||||
movie.isPartialObject() # Returns True
|
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.
|
# The movie object will be a full object.
|
||||||
movie.reload()
|
movie.reload()
|
||||||
movie.isFullObject() # Returns True
|
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)
|
return self._reload(key=key, **kwargs)
|
||||||
|
|
||||||
|
@ -505,25 +512,25 @@ class PlexPartialObject(PlexObject):
|
||||||
automatically and update itself.
|
automatically and update itself.
|
||||||
"""
|
"""
|
||||||
_INCLUDES = {
|
_INCLUDES = {
|
||||||
'checkFiles': 1,
|
'checkFiles': 0,
|
||||||
'includeAllConcerts': 1,
|
'includeAllConcerts': 0,
|
||||||
'includeBandwidths': 1,
|
'includeBandwidths': 1,
|
||||||
'includeChapters': 1,
|
'includeChapters': 1,
|
||||||
'includeChildren': 1,
|
'includeChildren': 0,
|
||||||
'includeConcerts': 1,
|
'includeConcerts': 0,
|
||||||
'includeExternalMedia': 1,
|
'includeExternalMedia': 0,
|
||||||
'includeExtras': 1,
|
'includeExtras': 0,
|
||||||
'includeFields': 'thumbBlurHash,artBlurHash',
|
'includeFields': 'thumbBlurHash,artBlurHash',
|
||||||
'includeGeolocation': 1,
|
'includeGeolocation': 1,
|
||||||
'includeLoudnessRamps': 1,
|
'includeLoudnessRamps': 1,
|
||||||
'includeMarkers': 1,
|
'includeMarkers': 1,
|
||||||
'includeOnDeck': 1,
|
'includeOnDeck': 0,
|
||||||
'includePopularLeaves': 1,
|
'includePopularLeaves': 0,
|
||||||
'includePreferences': 1,
|
'includePreferences': 0,
|
||||||
'includeRelated': 1,
|
'includeRelated': 0,
|
||||||
'includeRelatedCount': 1,
|
'includeRelatedCount': 0,
|
||||||
'includeReviews': 1,
|
'includeReviews': 0,
|
||||||
'includeStations': 1,
|
'includeStations': 0,
|
||||||
}
|
}
|
||||||
_EXCLUDES = {
|
_EXCLUDES = {
|
||||||
'excludeElements': (
|
'excludeElements': (
|
||||||
|
@ -592,7 +599,11 @@ class PlexPartialObject(PlexObject):
|
||||||
search result for a movie often only contain a portion of the attributes a full
|
search result for a movie often only contain a portion of the attributes a full
|
||||||
object (main url) for that movie would contain.
|
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):
|
def isPartialObject(self):
|
||||||
""" Returns True if this is not a full object. """
|
""" Returns True if this is not a full object. """
|
||||||
|
|
|
@ -197,7 +197,7 @@ class PlexClient(PlexObject):
|
||||||
raise NotFound(message)
|
raise NotFound(message)
|
||||||
else:
|
else:
|
||||||
raise BadRequest(message)
|
raise BadRequest(message)
|
||||||
data = response.text.encode('utf8')
|
data = utils.cleanXMLString(response.text).encode('utf8')
|
||||||
return ElementTree.fromstring(data) if data.strip() else None
|
return ElementTree.fromstring(data) if data.strip() else None
|
||||||
|
|
||||||
def sendCommand(self, command, proxy=None, **params):
|
def sendCommand(self, command, proxy=None, **params):
|
||||||
|
|
|
@ -60,6 +60,7 @@ class Collection(
|
||||||
title (str): Name of the collection.
|
title (str): Name of the collection.
|
||||||
titleSort (str): Title to use when sorting (defaults to title).
|
titleSort (str): Title to use when sorting (defaults to title).
|
||||||
type (str): 'collection'
|
type (str): 'collection'
|
||||||
|
ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object.
|
||||||
updatedAt (datetime): Datetime the collection was updated.
|
updatedAt (datetime): Datetime the collection was updated.
|
||||||
userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars).
|
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.title = data.attrib.get('title')
|
||||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
|
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
|
||||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||||
self._items = None # cache for self.items
|
self._items = None # cache for self.items
|
||||||
|
|
|
@ -4,6 +4,6 @@
|
||||||
# Library version
|
# Library version
|
||||||
MAJOR_VERSION = 4
|
MAJOR_VERSION = 4
|
||||||
MINOR_VERSION = 15
|
MINOR_VERSION = 15
|
||||||
PATCH_VERSION = 15
|
PATCH_VERSION = 16
|
||||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||||
|
|
|
@ -2823,7 +2823,8 @@ class FilteringType(PlexObject):
|
||||||
additionalFields.extend([
|
additionalFields.extend([
|
||||||
('duration', 'integer', 'Duration'),
|
('duration', 'integer', 'Duration'),
|
||||||
('viewOffset', 'integer', 'View Offset'),
|
('viewOffset', 'integer', 'View Offset'),
|
||||||
('label', 'tag', 'Label')
|
('label', 'tag', 'Label'),
|
||||||
|
('ratingCount', 'integer', 'Rating Count'),
|
||||||
])
|
])
|
||||||
elif self.type == 'collection':
|
elif self.type == 'collection':
|
||||||
additionalFields.extend([
|
additionalFields.extend([
|
||||||
|
|
|
@ -106,12 +106,16 @@ class MediaPart(PlexObject):
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Part'
|
TAG (str): 'Part'
|
||||||
accessible (bool): True if the file is accessible.
|
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.
|
audioProfile (str): The audio profile of the file.
|
||||||
container (str): The container type of the file (ex: avi).
|
container (str): The container type of the file (ex: avi).
|
||||||
decision (str): Unknown.
|
decision (str): Unknown.
|
||||||
deepAnalysisVersion (int): The Plex deep analysis version for the file.
|
deepAnalysisVersion (int): The Plex deep analysis version for the file.
|
||||||
duration (int): The duration of the file in milliseconds.
|
duration (int): The duration of the file in milliseconds.
|
||||||
exists (bool): True if the file exists.
|
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)
|
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.
|
has64bitOffsets (bool): True if the file has 64 bit offsets.
|
||||||
hasThumbnail (bool): True if the file (track) has an embedded thumbnail.
|
hasThumbnail (bool): True if the file (track) has an embedded thumbnail.
|
||||||
|
@ -999,6 +1003,28 @@ class Review(PlexObject):
|
||||||
self.text = data.attrib.get('text')
|
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):
|
class BaseResource(PlexObject):
|
||||||
""" Base class for all Art, Poster, and Theme objects.
|
""" Base class for all Art, Poster, and Theme objects.
|
||||||
|
|
||||||
|
|
|
@ -14,8 +14,8 @@ class AdvancedSettingsMixin:
|
||||||
|
|
||||||
def preferences(self):
|
def preferences(self):
|
||||||
""" Returns a list of :class:`~plexapi.settings.Preferences` objects. """
|
""" Returns a list of :class:`~plexapi.settings.Preferences` objects. """
|
||||||
data = self._server.query(self._details_key)
|
key = f'{self.key}?includePreferences=1'
|
||||||
return self.findItems(data, settings.Preferences, rtag='Preferences')
|
return self.fetchItems(key, cls=settings.Preferences, rtag='Preferences')
|
||||||
|
|
||||||
def preference(self, pref):
|
def preference(self, pref):
|
||||||
""" Returns a :class:`~plexapi.settings.Preferences` object for the specified 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)
|
params['agent'] = utils.getAgentIdentifier(self.section(), agent)
|
||||||
|
|
||||||
key = key + '?' + urlencode(params)
|
key = key + '?' + urlencode(params)
|
||||||
data = self._server.query(key, method=self._server._session.get)
|
return self.fetchItems(key, cls=media.SearchResult)
|
||||||
return self.findItems(data, initpath=key)
|
|
||||||
|
|
||||||
def fixMatch(self, searchResult=None, auto=False, agent=None):
|
def fixMatch(self, searchResult=None, auto=False, agent=None):
|
||||||
""" Use match result to update show metadata.
|
""" Use match result to update show metadata.
|
||||||
|
@ -278,8 +277,8 @@ class ExtrasMixin:
|
||||||
def extras(self):
|
def extras(self):
|
||||||
""" Returns a list of :class:`~plexapi.video.Extra` objects. """
|
""" Returns a list of :class:`~plexapi.video.Extra` objects. """
|
||||||
from plexapi.video import Extra
|
from plexapi.video import Extra
|
||||||
data = self._server.query(self._details_key)
|
key = f'{self.key}/extras'
|
||||||
return self.findItems(data, Extra, rtag='Extras')
|
return self.fetchItems(key, cls=Extra)
|
||||||
|
|
||||||
|
|
||||||
class HubsMixin:
|
class HubsMixin:
|
||||||
|
@ -289,8 +288,7 @@ class HubsMixin:
|
||||||
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
|
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
|
||||||
from plexapi.library import Hub
|
from plexapi.library import Hub
|
||||||
key = f'{self.key}/related'
|
key = f'{self.key}/related'
|
||||||
data = self._server.query(key)
|
return self.fetchItems(key, cls=Hub)
|
||||||
return self.findItems(data, Hub)
|
|
||||||
|
|
||||||
|
|
||||||
class PlayedUnplayedMixin:
|
class PlayedUnplayedMixin:
|
||||||
|
|
|
@ -250,7 +250,7 @@ class MyPlexAccount(PlexObject):
|
||||||
return response.json()
|
return response.json()
|
||||||
elif 'text/plain' in response.headers.get('Content-Type', ''):
|
elif 'text/plain' in response.headers.get('Content-Type', ''):
|
||||||
return response.text.strip()
|
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
|
return ElementTree.fromstring(data) if data.strip() else None
|
||||||
|
|
||||||
def ping(self):
|
def ping(self):
|
||||||
|
|
|
@ -768,7 +768,7 @@ class PlexServer(PlexObject):
|
||||||
raise NotFound(message)
|
raise NotFound(message)
|
||||||
else:
|
else:
|
||||||
raise BadRequest(message)
|
raise BadRequest(message)
|
||||||
data = response.text.encode('utf8')
|
data = utils.cleanXMLString(response.text).encode('utf8')
|
||||||
return ElementTree.fromstring(data) if data.strip() else None
|
return ElementTree.fromstring(data) if data.strip() else None
|
||||||
|
|
||||||
def search(self, query, mediatype=None, limit=None, sectionId=None):
|
def search(self, query, mediatype=None, limit=None, sectionId=None):
|
||||||
|
|
|
@ -6,6 +6,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import string
|
import string
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import warnings
|
import warnings
|
||||||
|
@ -673,3 +674,45 @@ def openOrRead(file):
|
||||||
def sha1hash(guid):
|
def sha1hash(guid):
|
||||||
""" Return the SHA1 hash of a guid. """
|
""" Return the SHA1 hash of a guid. """
|
||||||
return sha1(guid.encode('utf-8')).hexdigest()
|
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)
|
||||||
|
|
|
@ -375,6 +375,7 @@ class Movie(
|
||||||
studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment).
|
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?).
|
tagline (str): Movie tag line (Back 2 Work; Who says men can't change?).
|
||||||
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
||||||
|
ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object.
|
||||||
useOriginalTitle (int): Setting that indicates if the original title is used for the movie
|
useOriginalTitle (int): Setting that indicates if the original title is used for the movie
|
||||||
(-1 = Library default, 0 = No, 1 = Yes).
|
(-1 = Library default, 0 = No, 1 = Yes).
|
||||||
viewOffset (int): View offset in milliseconds.
|
viewOffset (int): View offset in milliseconds.
|
||||||
|
@ -420,6 +421,7 @@ class Movie(
|
||||||
self.studio = data.attrib.get('studio')
|
self.studio = data.attrib.get('studio')
|
||||||
self.tagline = data.attrib.get('tagline')
|
self.tagline = data.attrib.get('tagline')
|
||||||
self.theme = data.attrib.get('theme')
|
self.theme = data.attrib.get('theme')
|
||||||
|
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
|
||||||
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1'))
|
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1'))
|
||||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.writers = self.findItems(data, media.Writer)
|
self.writers = self.findItems(data, media.Writer)
|
||||||
|
@ -456,8 +458,8 @@ class Movie(
|
||||||
|
|
||||||
def reviews(self):
|
def reviews(self):
|
||||||
""" Returns a list of :class:`~plexapi.media.Review` objects. """
|
""" Returns a list of :class:`~plexapi.media.Review` objects. """
|
||||||
data = self._server.query(self._details_key)
|
key = f'{self.key}?includeReviews=1'
|
||||||
return self.findItems(data, media.Review, rtag='Video')
|
return self.fetchItems(key, cls=media.Review, rtag='Video')
|
||||||
|
|
||||||
def editions(self):
|
def editions(self):
|
||||||
""" Returns a list of :class:`~plexapi.video.Movie` objects
|
""" 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).
|
(-1 = Account default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled).
|
||||||
tagline (str): Show tag line.
|
tagline (str): Show tag line.
|
||||||
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
||||||
|
ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object.
|
||||||
useOriginalTitle (int): Setting that indicates if the original title is used for the show
|
useOriginalTitle (int): Setting that indicates if the original title is used for the show
|
||||||
(-1 = Library default, 0 = No, 1 = Yes).
|
(-1 = Library default, 0 = No, 1 = Yes).
|
||||||
viewedLeafCount (int): Number of items marked as played in the show view.
|
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.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1'))
|
||||||
self.tagline = data.attrib.get('tagline')
|
self.tagline = data.attrib.get('tagline')
|
||||||
self.theme = data.attrib.get('theme')
|
self.theme = data.attrib.get('theme')
|
||||||
|
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
|
||||||
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1'))
|
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1'))
|
||||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
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`.
|
""" Returns show's On Deck :class:`~plexapi.video.Video` object or `None`.
|
||||||
If show is unwatched, return will likely be the first episode.
|
If show is unwatched, return will likely be the first episode.
|
||||||
"""
|
"""
|
||||||
data = self._server.query(self._details_key)
|
key = f'{self.key}?includeOnDeck=1'
|
||||||
return next(iter(self.findItems(data, rtag='OnDeck')), None)
|
return next(iter(self.fetchItems(key, cls=Episode, rtag='OnDeck')), None)
|
||||||
|
|
||||||
def season(self, title=None, season=None):
|
def season(self, title=None, season=None):
|
||||||
""" Returns the season with the specified title or number.
|
""" Returns the season with the specified title or number.
|
||||||
|
@ -735,6 +739,7 @@ class Season(
|
||||||
subtitleLanguage (str): Setting that indicates the preferred subtitle language.
|
subtitleLanguage (str): Setting that indicates the preferred subtitle language.
|
||||||
subtitleMode (int): Setting that indicates the auto-select subtitle mode.
|
subtitleMode (int): Setting that indicates the auto-select subtitle mode.
|
||||||
(-1 = Series default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled).
|
(-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.
|
viewedLeafCount (int): Number of items marked as played in the season view.
|
||||||
year (int): Year the season was released.
|
year (int): Year the season was released.
|
||||||
"""
|
"""
|
||||||
|
@ -766,6 +771,7 @@ class Season(
|
||||||
self.ratings = self.findItems(data, media.Rating)
|
self.ratings = self.findItems(data, media.Rating)
|
||||||
self.subtitleLanguage = data.attrib.get('subtitleLanguage', '')
|
self.subtitleLanguage = data.attrib.get('subtitleLanguage', '')
|
||||||
self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1'))
|
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.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
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`.
|
""" 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.
|
Will only return a match if the show's On Deck episode is in this season.
|
||||||
"""
|
"""
|
||||||
data = self._server.query(self._details_key)
|
key = f'{self.key}?includeOnDeck=1'
|
||||||
return next(iter(self.findItems(data, rtag='OnDeck')), None)
|
return next(iter(self.fetchItems(key, cls=Episode, rtag='OnDeck')), None)
|
||||||
|
|
||||||
def episode(self, title=None, episode=None):
|
def episode(self, title=None, episode=None):
|
||||||
""" Returns the episode with the given title or number.
|
""" 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.
|
skipParent (bool): True if the show's seasons are set to hidden.
|
||||||
sourceURI (str): Remote server URI (server://<machineIdentifier>/com.plexapp.plugins.library)
|
sourceURI (str): Remote server URI (server://<machineIdentifier>/com.plexapp.plugins.library)
|
||||||
(remote playlist item only).
|
(remote playlist item only).
|
||||||
|
ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object.
|
||||||
viewOffset (int): View offset in milliseconds.
|
viewOffset (int): View offset in milliseconds.
|
||||||
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||||
year (int): Year the episode was released.
|
year (int): Year the episode was released.
|
||||||
|
@ -958,6 +965,7 @@ class Episode(
|
||||||
self.roles = self.findItems(data, media.Role)
|
self.roles = self.findItems(data, media.Role)
|
||||||
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0'))
|
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0'))
|
||||||
self.sourceURI = data.attrib.get('source') # remote playlist item
|
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.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.writers = self.findItems(data, media.Writer)
|
self.writers = self.findItems(data, media.Writer)
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
self.year = utils.cast(int, data.attrib.get('year'))
|
||||||
|
|
|
@ -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))})"
|
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_info__ = version_info(3, 1, 4, "final", 1)
|
||||||
__version_time__ = "06 Mar 2024 07:08 UTC"
|
__version_time__ = "25 Aug 2024 14:40 UTC"
|
||||||
__version__ = __version_info__.__version__
|
__version__ = __version_info__.__version__
|
||||||
__versionTime__ = __version_time__
|
__versionTime__ = __version_time__
|
||||||
__author__ = "Paul McGuire <ptmcg.gm+pyparsing@gmail.com>"
|
__author__ = "Paul McGuire <ptmcg.gm+pyparsing@gmail.com>"
|
||||||
|
@ -143,7 +143,7 @@ from .common import (
|
||||||
_builtin_exprs as common_builtin_exprs,
|
_builtin_exprs as common_builtin_exprs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# define backward compat synonyms
|
# Compatibility synonyms
|
||||||
if "pyparsing_unicode" not in globals():
|
if "pyparsing_unicode" not in globals():
|
||||||
pyparsing_unicode = unicode # type: ignore[misc]
|
pyparsing_unicode = unicode # type: ignore[misc]
|
||||||
if "pyparsing_common" not in globals():
|
if "pyparsing_common" not in globals():
|
||||||
|
|
|
@ -196,7 +196,7 @@ def with_class(classname, namespace=""):
|
||||||
return with_attribute(**{classattr: classname})
|
return with_attribute(**{classattr: classname})
|
||||||
|
|
||||||
|
|
||||||
# pre-PEP8 compatibility symbols
|
# Compatibility synonyms
|
||||||
# fmt: off
|
# fmt: off
|
||||||
replaceWith = replaced_by_pep8("replaceWith", replace_with)
|
replaceWith = replaced_by_pep8("replaceWith", replace_with)
|
||||||
removeQuotes = replaced_by_pep8("removeQuotes", remove_quotes)
|
removeQuotes = replaced_by_pep8("removeQuotes", remove_quotes)
|
||||||
|
|
|
@ -418,20 +418,15 @@ class pyparsing_common:
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
# pre-PEP8 compatibility names
|
# pre-PEP8 compatibility names
|
||||||
convertToInteger = convert_to_integer
|
# fmt: off
|
||||||
"""Deprecated - use :class:`convert_to_integer`"""
|
convertToInteger = staticmethod(replaced_by_pep8("convertToInteger", convert_to_integer))
|
||||||
convertToFloat = convert_to_float
|
convertToFloat = staticmethod(replaced_by_pep8("convertToFloat", convert_to_float))
|
||||||
"""Deprecated - use :class:`convert_to_float`"""
|
convertToDate = staticmethod(replaced_by_pep8("convertToDate", convert_to_date))
|
||||||
convertToDate = convert_to_date
|
convertToDatetime = staticmethod(replaced_by_pep8("convertToDatetime", convert_to_datetime))
|
||||||
"""Deprecated - use :class:`convert_to_date`"""
|
stripHTMLTags = staticmethod(replaced_by_pep8("stripHTMLTags", strip_html_tags))
|
||||||
convertToDatetime = convert_to_datetime
|
upcaseTokens = staticmethod(replaced_by_pep8("upcaseTokens", upcase_tokens))
|
||||||
"""Deprecated - use :class:`convert_to_datetime`"""
|
downcaseTokens = staticmethod(replaced_by_pep8("downcaseTokens", downcase_tokens))
|
||||||
stripHTMLTags = strip_html_tags
|
# fmt: on
|
||||||
"""Deprecated - use :class:`strip_html_tags`"""
|
|
||||||
upcaseTokens = upcase_tokens
|
|
||||||
"""Deprecated - use :class:`upcase_tokens`"""
|
|
||||||
downcaseTokens = downcase_tokens
|
|
||||||
"""Deprecated - use :class:`downcase_tokens`"""
|
|
||||||
|
|
||||||
|
|
||||||
_builtin_exprs = [
|
_builtin_exprs = [
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -36,6 +36,7 @@ jinja2_template_source = """\
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
{{ body | safe }}
|
{{ body | safe }}
|
||||||
{% for diagram in diagrams %}
|
{% for diagram in diagrams %}
|
||||||
<div class="railroad-group">
|
<div class="railroad-group">
|
||||||
|
@ -89,7 +90,7 @@ class AnnotatedItem(railroad.Group):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, label: str, item):
|
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]):
|
class EditablePartial(Generic[T]):
|
||||||
|
@ -145,7 +146,7 @@ def railroad_to_html(diagrams: List[NamedDiagram], embed=False, **kwargs) -> str
|
||||||
continue
|
continue
|
||||||
io = StringIO()
|
io = StringIO()
|
||||||
try:
|
try:
|
||||||
css = kwargs.get('css')
|
css = kwargs.get("css")
|
||||||
diagram.diagram.writeStandalone(io.write, css=css)
|
diagram.diagram.writeStandalone(io.write, css=css)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
diagram.diagram.writeSvg(io.write)
|
diagram.diagram.writeSvg(io.write)
|
||||||
|
@ -425,9 +426,11 @@ def _apply_diagram_item_enhancements(fn):
|
||||||
element_results_name = element.resultsName
|
element_results_name = element.resultsName
|
||||||
if element_results_name:
|
if element_results_name:
|
||||||
# add "*" to indicate if this is a "list all 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(
|
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
|
return ret
|
||||||
|
@ -534,7 +537,7 @@ def _to_diagram_element(
|
||||||
# (all will have the same name, and resultsName)
|
# (all will have the same name, and resultsName)
|
||||||
if not exprs:
|
if not exprs:
|
||||||
return None
|
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(
|
ret = EditablePartial.from_call(
|
||||||
railroad.OneOrMore, item="", repeat=str(len(exprs))
|
railroad.OneOrMore, item="", repeat=str(len(exprs))
|
||||||
)
|
)
|
||||||
|
@ -563,7 +566,7 @@ def _to_diagram_element(
|
||||||
if show_groups:
|
if show_groups:
|
||||||
ret = EditablePartial.from_call(AnnotatedItem, label="", item="")
|
ret = EditablePartial.from_call(AnnotatedItem, label="", item="")
|
||||||
else:
|
else:
|
||||||
ret = EditablePartial.from_call(railroad.Group, label="", item="")
|
ret = EditablePartial.from_call(railroad.Sequence, items=[])
|
||||||
elif isinstance(element, pyparsing.TokenConverter):
|
elif isinstance(element, pyparsing.TokenConverter):
|
||||||
label = type(element).__name__.lower()
|
label = type(element).__name__.lower()
|
||||||
if label == "tokenconverter":
|
if label == "tokenconverter":
|
||||||
|
@ -573,8 +576,36 @@ def _to_diagram_element(
|
||||||
elif isinstance(element, pyparsing.Opt):
|
elif isinstance(element, pyparsing.Opt):
|
||||||
ret = EditablePartial.from_call(railroad.Optional, item="")
|
ret = EditablePartial.from_call(railroad.Optional, item="")
|
||||||
elif isinstance(element, pyparsing.OneOrMore):
|
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):
|
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="")
|
ret = EditablePartial.from_call(railroad.ZeroOrMore, item="")
|
||||||
elif isinstance(element, pyparsing.Group):
|
elif isinstance(element, pyparsing.Group):
|
||||||
ret = EditablePartial.from_call(
|
ret = EditablePartial.from_call(
|
||||||
|
|
|
@ -85,7 +85,7 @@ class ParseBaseException(Exception):
|
||||||
ret = []
|
ret = []
|
||||||
if isinstance(exc, ParseBaseException):
|
if isinstance(exc, ParseBaseException):
|
||||||
ret.append(exc.line)
|
ret.append(exc.line)
|
||||||
ret.append(" " * (exc.column - 1) + "^")
|
ret.append(f"{' ' * (exc.column - 1)}^")
|
||||||
ret.append(f"{type(exc).__name__}: {exc}")
|
ret.append(f"{type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
if depth <= 0:
|
if depth <= 0:
|
||||||
|
@ -245,6 +245,7 @@ class ParseBaseException(Exception):
|
||||||
"""
|
"""
|
||||||
return self.explain_exception(self, depth)
|
return self.explain_exception(self, depth)
|
||||||
|
|
||||||
|
# Compatibility synonyms
|
||||||
# fmt: off
|
# fmt: off
|
||||||
markInputline = replaced_by_pep8("markInputline", mark_input_line)
|
markInputline = replaced_by_pep8("markInputline", mark_input_line)
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
|
@ -782,9 +782,12 @@ def infix_notation(
|
||||||
|
|
||||||
# if lpar and rpar are not suppressed, wrap in group
|
# if lpar and rpar are not suppressed, wrap in group
|
||||||
if not (isinstance(lpar, Suppress) and isinstance(rpar, Suppress)):
|
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:
|
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
|
arity: int
|
||||||
rightLeftAssoc: opAssoc
|
rightLeftAssoc: opAssoc
|
||||||
|
@ -855,6 +858,7 @@ def infix_notation(
|
||||||
thisExpr <<= (matchExpr | lastExpr).setName(term_name)
|
thisExpr <<= (matchExpr | lastExpr).setName(term_name)
|
||||||
lastExpr = thisExpr
|
lastExpr = thisExpr
|
||||||
ret <<= lastExpr
|
ret <<= lastExpr
|
||||||
|
root_expr.set_name("base_expr")
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
@ -1049,7 +1053,7 @@ def delimited_list(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# pre-PEP8 compatible names
|
# Compatibility synonyms
|
||||||
# fmt: off
|
# fmt: off
|
||||||
opAssoc = OpAssoc
|
opAssoc = OpAssoc
|
||||||
anyOpenTag = any_open_tag
|
anyOpenTag = any_open_tag
|
||||||
|
|
|
@ -4,12 +4,14 @@ from collections.abc import (
|
||||||
Mapping,
|
Mapping,
|
||||||
MutableSequence,
|
MutableSequence,
|
||||||
Iterator,
|
Iterator,
|
||||||
Sequence,
|
Iterable,
|
||||||
Container,
|
|
||||||
)
|
)
|
||||||
import pprint
|
import pprint
|
||||||
from typing import Tuple, Any, Dict, Set, List
|
from typing import Tuple, Any, Dict, Set, List
|
||||||
|
|
||||||
|
from .util import replaced_by_pep8
|
||||||
|
|
||||||
|
|
||||||
str_type: Tuple[type, ...] = (str, bytes)
|
str_type: Tuple[type, ...] = (str, bytes)
|
||||||
_generator_type = type((_ for _ in ()))
|
_generator_type = type((_ for _ in ()))
|
||||||
|
|
||||||
|
@ -573,20 +575,20 @@ class ParseResults:
|
||||||
# replace values with copies if they are of known mutable types
|
# replace values with copies if they are of known mutable types
|
||||||
for i, obj in enumerate(self._toklist):
|
for i, obj in enumerate(self._toklist):
|
||||||
if isinstance(obj, ParseResults):
|
if isinstance(obj, ParseResults):
|
||||||
self._toklist[i] = obj.deepcopy()
|
ret._toklist[i] = obj.deepcopy()
|
||||||
elif isinstance(obj, (str, bytes)):
|
elif isinstance(obj, (str, bytes)):
|
||||||
pass
|
pass
|
||||||
elif isinstance(obj, MutableMapping):
|
elif isinstance(obj, MutableMapping):
|
||||||
self._toklist[i] = dest = type(obj)()
|
ret._toklist[i] = dest = type(obj)()
|
||||||
for k, v in obj.items():
|
for k, v in obj.items():
|
||||||
dest[k] = v.deepcopy() if isinstance(v, ParseResults) else v
|
dest[k] = v.deepcopy() if isinstance(v, ParseResults) else v
|
||||||
elif isinstance(obj, Container):
|
elif isinstance(obj, Iterable):
|
||||||
self._toklist[i] = type(obj)(
|
ret._toklist[i] = type(obj)(
|
||||||
v.deepcopy() if isinstance(v, ParseResults) else v for v in obj
|
v.deepcopy() if isinstance(v, ParseResults) else v for v in obj
|
||||||
)
|
)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def get_name(self):
|
def get_name(self) -> str:
|
||||||
r"""
|
r"""
|
||||||
Returns the results name for this token expression. Useful when several
|
Returns the results name for this token expression. Useful when several
|
||||||
different expressions might match at a particular location.
|
different expressions might match at a particular location.
|
||||||
|
|
|
@ -53,51 +53,51 @@ class unicode_set:
|
||||||
_ranges: UnicodeRangeList = []
|
_ranges: UnicodeRangeList = []
|
||||||
|
|
||||||
@_lazyclassproperty
|
@_lazyclassproperty
|
||||||
def _chars_for_ranges(cls):
|
def _chars_for_ranges(cls) -> List[str]:
|
||||||
ret = []
|
ret: List[int] = []
|
||||||
for cc in cls.__mro__:
|
for cc in cls.__mro__:
|
||||||
if cc is unicode_set:
|
if cc is unicode_set:
|
||||||
break
|
break
|
||||||
for rr in getattr(cc, "_ranges", ()):
|
for rr in getattr(cc, "_ranges", ()):
|
||||||
ret.extend(range(rr[0], rr[-1] + 1))
|
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
|
@_lazyclassproperty
|
||||||
def printables(cls):
|
def printables(cls) -> str:
|
||||||
"""all non-whitespace characters in this range"""
|
"""all non-whitespace characters in this range"""
|
||||||
return "".join(filterfalse(str.isspace, cls._chars_for_ranges))
|
return "".join(filterfalse(str.isspace, cls._chars_for_ranges))
|
||||||
|
|
||||||
@_lazyclassproperty
|
@_lazyclassproperty
|
||||||
def alphas(cls):
|
def alphas(cls) -> str:
|
||||||
"""all alphabetic characters in this range"""
|
"""all alphabetic characters in this range"""
|
||||||
return "".join(filter(str.isalpha, cls._chars_for_ranges))
|
return "".join(filter(str.isalpha, cls._chars_for_ranges))
|
||||||
|
|
||||||
@_lazyclassproperty
|
@_lazyclassproperty
|
||||||
def nums(cls):
|
def nums(cls) -> str:
|
||||||
"""all numeric digit characters in this range"""
|
"""all numeric digit characters in this range"""
|
||||||
return "".join(filter(str.isdigit, cls._chars_for_ranges))
|
return "".join(filter(str.isdigit, cls._chars_for_ranges))
|
||||||
|
|
||||||
@_lazyclassproperty
|
@_lazyclassproperty
|
||||||
def alphanums(cls):
|
def alphanums(cls) -> str:
|
||||||
"""all alphanumeric characters in this range"""
|
"""all alphanumeric characters in this range"""
|
||||||
return cls.alphas + cls.nums
|
return cls.alphas + cls.nums
|
||||||
|
|
||||||
@_lazyclassproperty
|
@_lazyclassproperty
|
||||||
def identchars(cls):
|
def identchars(cls) -> str:
|
||||||
"""all characters in this range that are valid identifier characters, plus underscore '_'"""
|
"""all characters in this range that are valid identifier characters, plus underscore '_'"""
|
||||||
return "".join(
|
return "".join(
|
||||||
sorted(
|
sorted(
|
||||||
set(
|
set(filter(str.isidentifier, cls._chars_for_ranges))
|
||||||
"".join(filter(str.isidentifier, cls._chars_for_ranges))
|
| set(
|
||||||
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzªµº"
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzªµº"
|
||||||
+ "ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ"
|
"ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ"
|
||||||
+ "_"
|
"_"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@_lazyclassproperty
|
@_lazyclassproperty
|
||||||
def identbodychars(cls):
|
def identbodychars(cls) -> str:
|
||||||
"""
|
"""
|
||||||
all characters in this range that are valid identifier body characters,
|
all characters in this range that are valid identifier body characters,
|
||||||
plus the digits 0-9, and · (Unicode MIDDLE DOT)
|
plus the digits 0-9, and · (Unicode MIDDLE DOT)
|
||||||
|
@ -105,7 +105,9 @@ class unicode_set:
|
||||||
identifier_chars = set(
|
identifier_chars = set(
|
||||||
c for c in cls._chars_for_ranges if ("_" + c).isidentifier()
|
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
|
@_lazyclassproperty
|
||||||
def identifier(cls):
|
def identifier(cls):
|
||||||
|
|
|
@ -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
|
# (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.)
|
# 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)
|
@wraps(fn)
|
||||||
def _inner(self, *args, **kwargs):
|
def _inner(self, *args, **kwargs):
|
||||||
|
|
|
@ -22,8 +22,8 @@ from pytz.tzfile import build_tzinfo
|
||||||
|
|
||||||
|
|
||||||
# The IANA (nee Olson) database is updated several times a year.
|
# The IANA (nee Olson) database is updated several times a year.
|
||||||
OLSON_VERSION = '2024a'
|
OLSON_VERSION = '2024b'
|
||||||
VERSION = '2024.1' # pip compatible version number.
|
VERSION = '2024.2' # pip compatible version number.
|
||||||
__version__ = VERSION
|
__version__ = VERSION
|
||||||
|
|
||||||
OLSEN_VERSION = OLSON_VERSION # Old releases had this misspelling
|
OLSEN_VERSION = OLSON_VERSION # Old releases had this misspelling
|
||||||
|
@ -1340,7 +1340,6 @@ common_timezones = \
|
||||||
'Asia/Bishkek',
|
'Asia/Bishkek',
|
||||||
'Asia/Brunei',
|
'Asia/Brunei',
|
||||||
'Asia/Chita',
|
'Asia/Chita',
|
||||||
'Asia/Choibalsan',
|
|
||||||
'Asia/Colombo',
|
'Asia/Colombo',
|
||||||
'Asia/Damascus',
|
'Asia/Damascus',
|
||||||
'Asia/Dhaka',
|
'Asia/Dhaka',
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue