Compare commits

...
Author SHA1 Message Date
dgtlmoon 68ed17e45f test fix 2026-09-01 17:24:15 +02:00
dgtlmoon 53fd36e482 Merge branch 'master' into memory/import-diet 2026-09-01 17:21:23 +02:00
dgtlmoon 6a7fae625b WIP 2026-09-01 16:59:30 +02:00
dgtlmoon e765588089 Memory usage - small improvement by tidying up module imports 2026-09-01 16:50:58 +02:00
10 changed files with 30 additions and 28 deletions
@@ -1,7 +1,6 @@
import re
import pluggy
from price_parser import Price
from loguru import logger
from flask_babel import lazy_gettext as _l
@@ -70,6 +69,7 @@ def register_field_choices():
@hookimpl
def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
from price_parser import Price
res = {}
if 'text' in ephemeral_data:
res['page_filtered_text'] = ephemeral_data['text']
+2 -2
View File
@@ -696,14 +696,14 @@ class ValidateCSSJSONXPATHInput(object):
raise ValidationError("XPath not permitted in this field!")
from lxml import etree, html
import elementpath
from changedetectionio.html_tools import SafeXPath3Parser, lxml_guard, lxml_html_parser
from changedetectionio.html_tools import get_safe_xpath3_parser, lxml_guard, lxml_html_parser
line = line.replace('xpath:', '')
try:
# Runs on a Flask request thread - must share the worker's lxml lock.
with lxml_guard():
tree = html.fromstring("<html></html>", parser=lxml_html_parser())
elementpath.select(tree, line.strip(), parser=SafeXPath3Parser)
elementpath.select(tree, line.strip(), parser=get_safe_xpath3_parser())
except elementpath.ElementPathError as e:
message = field.gettext('\'%(expression)s\' is not a valid XPath expression. (%(error)s)')
raise ValidationError(message % {'expression': line, 'error': str(e)})
+2 -5
View File
@@ -164,7 +164,7 @@ _DEFAULT_UNSAFE_XPATH3_FUNCTIONS = [
]
def _build_safe_xpath3_parser():
def get_safe_xpath3_parser():
"""Return an XPath3Parser subclass with filesystem/environment access functions removed.
XPath 3.0 includes functions that can read arbitrary files or environment variables:
@@ -196,9 +196,6 @@ def _build_safe_xpath3_parser():
return SafeXPath3Parser
# Module-level singleton — built once, reused everywhere.
SafeXPath3Parser = _build_safe_xpath3_parser()
# Doesn't look like python supports forward slash auto enclosure in re.findall
# So convert it to inline flag "(?i)foobar" type configuration
@lru_cache(maxsize=100)
@@ -386,7 +383,7 @@ def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False
# This allows //title to match elements in the default namespace
namespaces[''] = tree.nsmap[None]
r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces, parser=SafeXPath3Parser)
r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces, parser=get_safe_xpath3_parser())
#@note: //title/text() now works with default namespaces (fixed by registering '' prefix)
#@note: //title/text() wont work where <title>CDATA.. (use cdata_in_document_to_text first)
@@ -80,7 +80,6 @@ Note:
This extension uses the Arrow library for timezone-aware datetime handling.
All timezone names should be valid IANA timezone identifiers (e.g., 'America/New_York').
"""
import arrow
from jinja2 import nodes
from jinja2.ext import Extension
@@ -125,6 +124,7 @@ class TimeExtension(Extension):
_datetime('UTC', '+', 'hours=2,minutes=30', '%Y-%m-%d %H:%M:%S')
# Returns current time + 2.5 hours
"""
import arrow
# Use default timezone if none specified
if not timezone or timezone == '':
timezone = self.environment.default_timezone
@@ -162,6 +162,7 @@ class TimeExtension(Extension):
_now('America/New_York', '%Y-%m-%d %H:%M:%S')
# Returns current time in New York timezone
"""
import arrow
# Use default timezone if none specified
if not timezone or timezone == '':
timezone = self.environment.default_timezone
+5 -3
View File
@@ -1,11 +1,8 @@
import time
import re
import apprise
from apprise import NotifyFormat
from loguru import logger
from urllib.parse import urlparse
from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL
from .email_helpers import as_monospaced_html_email
from ..diff import HTML_REMOVED_STYLE, REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED, ADDED_PLACEMARKER_OPEN, HTML_ADDED_STYLE, \
ADDED_PLACEMARKER_CLOSED, CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED, CHANGED_PLACEMARKER_OPEN, \
@@ -64,6 +61,7 @@ def notification_format_align_with_apprise(n_format : str):
:param n_format:
:return:
"""
from apprise import NotifyFormat
if not n_format:
return NotifyFormat.TEXT.value
@@ -208,6 +206,7 @@ def replace_placemarkers_in_text(text, url, requested_output_format):
def apply_service_tweaks(url, n_body, n_title, requested_output_format):
from .apprise_plugin.assets import APPRISE_AVATAR_URL
logger.debug(f"Applying markup in '{requested_output_format}' mode")
# Re 323 - Limit discord length to their 2000 char limit total or it wont send.
@@ -305,6 +304,9 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
def process_notification(n_object: NotificationContextData, datastore):
import apprise
from apprise import NotifyFormat
from .apprise_plugin.assets import apprise_asset
from changedetectionio.jinja2_custom import render as jinja_render
from . import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH, default_notification_format, valid_notification_formats
# be sure its registered
+3 -2
View File
@@ -132,8 +132,9 @@ def environment(mocker):
# Fixed datetime: Wed, 09 Dec 2015 23:33:01 UTC
# This is calculated to match the test expectations when offsets are applied
fixed_datetime = arrow.Arrow(2015, 12, 9, 23, 33, 1, tzinfo='UTC')
# Patch arrow.now in the TimeExtension module where it's actually used
mocker.patch('changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now', return_value=fixed_datetime)
# Patch on the arrow module itself - TimeExtension imports arrow lazily inside its methods
# (import diet), so there is no module-level TimeExtension.arrow attribute to patch
mocker.patch('arrow.now', return_value=fixed_datetime)
return fixed_datetime
+2 -2
View File
@@ -85,7 +85,7 @@ def test_timezone(mocker):
timezone = 'America/Buenos_Aires'
currentDate = arrow.now(timezone)
arrowNowMock = mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now")
arrowNowMock = mocker.patch("arrow.now")
arrowNowMock.return_value = currentDate
finalRender = render(f"{{% now '{timezone}' %}}")
@@ -115,7 +115,7 @@ def test_add_weekday(mocker):
timezone = 'utc'
currentDate = arrow.now(timezone)
arrowNowMock = mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now")
arrowNowMock = mocker.patch("arrow.now")
arrowNowMock.return_value = currentDate
finalRender = render(f"{{% now '{timezone}' + 'weekday=1' %}}")
@@ -595,11 +595,11 @@ def test_rss_xpath(client, live_server, measure_memory_usage, datastore_path):
# GHSA-6fmw-82m7-jq6p — XPath arbitrary file read via unparsed-text() and friends
# Unit-level: verify xpath_filter() and SafeXPath3Parser block all dangerous functions.
# Unit-level: verify xpath_filter() and the safe XPath3 parser block all dangerous functions.
def test_xpath_blocked_functions_unit():
"""Dangerous XPath 3.0 functions must be rejected at the parser level (no live server needed)."""
import elementpath
from changedetectionio.html_tools import xpath_filter, SafeXPath3Parser
from changedetectionio.html_tools import xpath_filter, get_safe_xpath3_parser
from lxml import html
html_content = '<html><body><p>safe content</p></body></html>'
@@ -627,11 +627,11 @@ def test_xpath_blocked_functions_unit():
except elementpath.ElementPathError:
pass # expected
# SafeXPath3Parser must reject the expression at parse time
# the safe parser must reject the expression at parse time
tree = html.fromstring(html_content)
try:
elementpath.select(tree, expr, parser=SafeXPath3Parser)
assert False, f"SafeXPath3Parser should have raised for: {expr!r}"
elementpath.select(tree, expr, parser=get_safe_xpath3_parser())
assert False, f"safe XPath3 parser should have raised for: {expr!r}"
except elementpath.ElementPathError:
pass # expected
@@ -23,7 +23,7 @@ def test_default_timezone_override_like_safe_jinja(mocker):
# Mock arrow.now to return a fixed time
fixed_time = arrow.Arrow(2025, 1, 15, 12, 0, 0, tzinfo='America/New_York')
mock = mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
mock = mocker.patch("arrow.now", return_value=fixed_time)
# Use empty string timezone - should use the overridden default
template_str = "{% now '' %}"
@@ -46,7 +46,7 @@ def test_default_timezone_not_overridden(mocker):
# Mock arrow.now
fixed_time = arrow.Arrow(2025, 1, 15, 17, 0, 0, tzinfo='UTC')
mock = mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
mock = mocker.patch("arrow.now", return_value=fixed_time)
# Use empty string timezone - should use 'UTC' default
template_str = "{% now '' %}"
@@ -69,7 +69,7 @@ def test_datetime_format_override_like_safe_jinja(mocker):
# Mock arrow.now
fixed_time = arrow.Arrow(2025, 1, 15, 14, 30, 45, tzinfo='UTC')
mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
mocker.patch("arrow.now", return_value=fixed_time)
# Don't specify format - should use overridden default
template_str = "{% now 'UTC' %}"
@@ -89,7 +89,7 @@ def test_offset_with_overridden_timezone(mocker):
jinja2_env.default_timezone = 'Europe/London'
fixed_time = arrow.Arrow(2025, 1, 15, 10, 0, 0, tzinfo='Europe/London')
mock = mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
mock = mocker.patch("arrow.now", return_value=fixed_time)
# Use offset with empty timezone string
template_str = "{% now '' + 'hours=2', '%Y-%m-%d %H:%M:%S' %}"
@@ -110,7 +110,7 @@ def test_weekday_parameter_converted_to_int(mocker):
# Wednesday, Jan 15, 2025
fixed_time = arrow.Arrow(2025, 1, 15, 12, 0, 0, tzinfo='UTC')
mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
mocker.patch("arrow.now", return_value=fixed_time)
# Add offset to next Monday (weekday=0)
template_str = "{% now 'UTC' + 'weekday=0', '%A' %}"
@@ -127,7 +127,7 @@ def test_multiple_offset_parameters(mocker):
jinja2_env = ImmutableSandboxedEnvironment(extensions=[TimeExtension])
fixed_time = arrow.Arrow(2025, 1, 15, 10, 30, 45, tzinfo='UTC')
mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
mocker.patch("arrow.now", return_value=fixed_time)
# Test multiple parameters: days, hours, minutes, seconds
template_str = "{% now 'UTC' + 'days=1,hours=2,minutes=15,seconds=10', '%Y-%m-%d %H:%M:%S' %}"
+2 -1
View File
@@ -1,7 +1,6 @@
import os
from functools import lru_cache
import arrow
from enum import IntEnum
@@ -55,6 +54,7 @@ def am_i_inside_time(
Returns:
bool: True if the current time is within the time range, False otherwise.
"""
import arrow
# Parse the target day of the week
try:
target_weekday = Weekday[day_of_week.capitalize()]
@@ -114,6 +114,7 @@ def is_within_schedule(time_schedule_limit, default_tz="UTC"):
Returns:
bool: True if current time is within the schedule, False otherwise.
"""
import arrow
if time_schedule_limit and time_schedule_limit.get('enabled'):
# Get the timezone the time schedule is in, so we know what day it is there
tz_name = time_schedule_limit.get('timezone')