Compare commits

...
Author SHA1 Message Date
dgtlmoon 0e77c5aad7 Additional expanded menu mode 2026-09-09 14:49:26 +02:00
dgtlmoon d7e83e53b6 UI - Left menu folds out ONTOP of content, Improving VisualSelector & BrowserSteps width, Darkmode transitions off. #4380 2026-09-09 13:53:16 +02:00
dgtlmoon 0f4b556af0 0.60.3
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
2026-09-04 14:12:00 +02:00
dgtlmoon 7a3bc2ab9e GHSA-56fq-63vj-9992 Add-watch-UI should be POST/CSRF protected @AmerMrkaljevic (#4375) 2026-09-04 12:39:36 +02:00
dgtlmoon 3a71777499 UI - Fixing lots of actions that should be a POST style action which led to a 404
Also solves some of GHSA-56fq-63vj-9992 Add-watch-UI should be POST/CSRF protected
2026-09-04 12:38:20 +02:00
dgtlmoon e0fb224d41 UI - Fixing lots of actions that should be a POST style action which led to a 404 (#4370) 2026-09-04 10:56:23 +02:00
dgtlmoon 050172129e Translations - support for translation overlays/local translations of existing strings (#4365)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
* Translations - support for translation overlays/local translations of existing strings

* Dont forget translations_overlay for pip
2026-09-03 11:15:46 +02:00
dgtlmoon e573837789 0.60.2 2026-09-03 09:15:35 +02:00
Andrew PeabodyandAndrew Peabody 3d6de42924 fix(conditions): support zero values in condition filtering and json logic conversion (#4362)
* fix(conditions): support zero values in condition filtering and json logic conversion

- Fix filter_complete_rules dropping rules where value is 0/0.0 due to 0 == False in Python
- Fix convert_to_jsonlogic raising EmptyConditionRuleRowNotUsable on value=0 due to truthiness check
- Fix str != 'None' type comparison typo in convert_to_jsonlogic
- Add comprehensive unit tests covering zero value condition filtering, conversion, and execution

* chore: re-trigger CI checks

---------

Co-authored-by: Andrew Peabody <apeabody@users.noreply.github.com>
2026-09-03 08:57:34 +02:00
dgtlmoonandClaude Opus 5 fbd9472218 fix(api): accept an existing tag UUID in the watch tag field, and deprecate it (#4361)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
`tag` on POST /watch was documented as taking a tag UUID, but the value went to
add_tag(title): a UUID silently created a junk tag *titled* with that UUID and never
applied the tag the caller asked for. `tags` (UUIDs) was the only thing that worked.

- `tag=` now resolves an existing tag UUID to that tag, still falling back to title
  matching/creation for names. A UUID-shaped value matching nothing is skipped with a
  warning rather than becoming a group named after a UUID.
- Blank tokens ("One,,Two,") no longer store False in watch['tags'] - add_tag() returns
  False for an empty title and it was appended unguarded. Consumers tolerate it
  (get_all_tags_for_watch() dictfilt()s over known tags) but it is not valid data.
- add_tag()'s title search is extracted to tag_uuid_for_title(), so existence can be
  tested without creating as a side effect. add_tag()'s contract is unchanged.
- api-spec: `tag` is marked `deprecated: true` (so Redoc renders the badge) and states
  plainly that it takes names, not UUIDs. `tags` now says what it really does - applied
  verbatim, never creates, unknown UUIDs stored as dangling refs. Rendered docs rebuilt.

Every claim in the new field docs is asserted in test_api_tags.py against the real API.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:46:27 +02:00
dgtlmoonandClaude Opus 5 2669a5cee6 Feature/Watch limit - Adding test (#4360)
* Env var - PAGE_WATCH_LIMIT enhancements

* Rebuild API docs

* Bump APi doc version

* test: cover PAGE_WATCH_LIMIT across every add path

- API create returns 429; API import returns 429 and refuses the batch whole
- quick-add and the UI importer flash the limit (importer once per file, not per row)
  and hand unimported URLs back
- clone at the limit no longer KeyErrors
- an instance already over the limit still loads from disk and stays editable, only
  new watches are refused
- the Info tab shows the limit only when one is set
- add_watch() with no request context returns None instead of raising from flash()
- an absent, empty or unparseable env var all mean unlimited

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 15:43:59 +02:00
dgtlmoon 68a445df1b Env var - PAGE_WATCH_LIMIT enhancements (#4359) 2026-09-02 13:37:55 +02:00
dgtlmoon af234636b9 Watch loading - Dont crawl whole disk, just check each directory for a watch.json/tag.json (#4358)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
2026-09-02 11:10:49 +02:00
Andrew Peabody 7708ffa986 fix(llm): support Gemini flash-lite & reasoning models, strip extra_body on 400 retry (#4342)
- Define _NO_TEMPERATURE_MODEL_KEYWORDS constant and omit temperature=0 on initial request
- Omit thinkingConfig in _thinking_extra_body for flash-lite models
- Unconditionally strip rejected sampling params and extra_body thinkingConfig on BadRequestError (handling Gemini 400 INVALID_ARGUMENT without named details)
- Add comprehensive unit tests in test_llm_client_gemini.py
2026-09-02 10:45:39 +02:00
dgtlmoon 59c9e9e4be Docs - rebuilding API docs 2026-09-02 10:26:26 +02:00
dgtlmoon f9e3627d82 Update - pyppeteer-ng to 2.0.0rc16 (#4357) 2026-09-02 10:23:27 +02:00
dgtlmoon 97317398ac LLM - Per watch/group settings and UI tidyup (#4354)
* UI - LLM section tidyup

* Rebuild translatiosn

* UI - Fixing language for prompt adjustement to be more clear

* UI - clarify field action

* UI - clarify layout for sections

* WIP

* test tweaks
2026-09-02 10:07:34 +02:00
dgtlmoon 10521dd5e1 UTF-8/Lone Surrogate encoding test improvements related to #4273 (#4353)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
2026-09-01 17:55:13 +02:00
dgtlmoon 227b011d73 Memory usage - small improvement by tidying up module imports (#4352) 2026-09-01 17:50:50 +02:00
dgtlmoon 780c8dc8af Pause Queue - Best to pause it in the watch loop so that it works with very long lists of watches 2026-09-01 17:45:51 +02:00
dgtlmoon dce6f23cf9 Scheduler+API Bug - if an invalid timezone was set (through edit of watch or API) it could have crashed the scheduler, Added timezone to the official API docs (#4350)
* Scheduler+API Bug - if an invalid timezone was set (through edit of watch or API) it could have crashed the scheduler, Added `timezone` to the official API docs

* adding missing files
2026-09-01 17:00:03 +02:00
dgtlmoon 2b0c5e24e6 Memory - GC freeze after startup to save CPU cycles and improve memory management (#4351) 2026-09-01 16:59:58 +02:00
dgtlmoonandClaude Opus 5 143a0f116f fix(llm): handle reasoning blocks that aren't a well-formed <think>...</think> pair (#4349)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
Follow-up to #4340. The <think> stripper added there only matches a closed pair, but a
reasoning scratchpad routinely contains JSON of its own ("initially I thought
{"important": false}, but..."), so any leftover scratchpad lets _extract_json lock onto
a discarded intermediate answer. Three shapes slipped through, all of which inverted the
verdict to important=false and therefore silently suppressed the notification:

- opener stripped by the provider/chat template, only </think> comes back over the wire
- <thinking> spelled out in full
- unterminated block, i.e. the response was cut off mid-thought by max_tokens

The first two are now stripped. The third raises ValueError, because a truncated response
holds no answer at all - only the abandoned guess. Raising routes it to the existing
handler in evaluator.py, which passes the change through as important rather than dropping
it; parse_eval_response deliberately does not catch ValueError, since its own fallback
(important=False) would suppress the notification instead.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 12:42:16 +02:00
Andrew Peabody 9e54ed3adb fix(llm): string boolean parsing, reasoning model <think> stripping, and timestamp diff annotation (#4340)
- Simplify _to_bool using changedetectionio.strtobool.strtobool
- Strip <think>...</think> reasoning blocks in _extract_json for reasoning models
- Fix _annotate_moved_lines short-circuit so standalone relative timestamps are always annotated
- Add comprehensive unit tests in test_response_parser and test_prompt_builder
2026-09-01 11:52:40 +02:00
dgtlmoon 7b61c20e68 Python 3.10 EOL October/2026 - Dropping support - conflicts with jsomschema and litellm (#4347)
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
2026-08-31 21:19:12 +02:00
139 changed files with 5934 additions and 1093 deletions
+13 -9
View File
@@ -52,6 +52,19 @@ jobs:
git diff --stat changedetectionio/translations
exit 1
fi
- name: Check translation overlay
# Deliberately after extract_messages above, so overrides are validated against a freshly
# extracted messages.pot. An overlay entry is keyed on the exact upstream msgid: when a
# string is reworded upstream the override stops matching and silently reverts to upstream
# wording. This is the only thing that makes that visible.
# See changedetectionio/translations_overlay/README.md
if: hashFiles('changedetectionio/translations_overlay/**/*.po') != ''
run: |
find changedetectionio/translations_overlay -name "*.po" | while read f; do
echo "Checking $f"
msgfmt --check-format -o /dev/null "$f"
done
python changedetectionio/translations_overlay/manage.py check
lint-template-i18n:
runs-on: ubuntu-latest
@@ -103,15 +116,6 @@ jobs:
sys.exit(1)
PYEOF
test-application-3-10:
# Only run on push to master (including PR merges)
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: [lint-code, lint-translations, lint-template-i18n]
uses: ./.github/workflows/test-stack-reusable-workflow.yml
with:
python-version: '3.10'
test-application-3-11:
# Always run
needs: [lint-code, lint-translations, lint-template-i18n]
+1 -1
View File
@@ -1,5 +1,5 @@
# Minimum supported version
target-version = "py310"
target-version = "py311"
# Formatting options
line-length = 100
+1
View File
@@ -13,6 +13,7 @@ recursive-include changedetectionio/store *
recursive-include changedetectionio/templates *
recursive-include changedetectionio/tests *
recursive-include changedetectionio/translations *
recursive-include changedetectionio/translations_overlay *
recursive-include changedetectionio/widgets *
prune changedetectionio/static/package-lock.json
prune changedetectionio/static/styles/node_modules
+1 -1
View File
@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
__version__ = '0.55.8'
__version__ = '0.60.3'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
+9
View File
@@ -194,6 +194,15 @@ class Import(Resource):
urls_to_import.append(url)
# PAGE_WATCH_LIMIT - refuse the whole batch rather than importing an arbitrary prefix of
# it, so a 429 always means "nothing was created" and the caller can retry as-is
watch_limit = self.datastore.watch_limit
if watch_limit is not None:
current_watch_count = len(self.datastore.data['watching'])
if current_watch_count + len(urls_to_import) > watch_limit:
return (f"Watch limit reached ({current_watch_count}/{watch_limit} watches), importing "
f"{len(urls_to_import)} URL(s) would exceed it. No watches were imported.", 429)
# For small imports, process synchronously for immediate feedback
if len(urls_to_import) < IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD:
added = []
+52 -10
View File
@@ -18,6 +18,42 @@ from ..notification import valid_notification_formats
from ..notification.handler import newline_re
def validate_time_schedule_limit(json_data):
"""
Validate the optional timezone inside time_schedule_limit.
The edit form runs validateTimeZoneName on this field, but the API did not -
the OpenAPI schema for time_schedule_limit does not declare a `timezone`
property at all and does not set additionalProperties:false, so any string
passed straight through and was stored.
That matters because the scheduler resolves it with arrow.now(tz), which
raises on an unknown zone. Before the ticker thread was hardened, a single
authenticated PUT with a bogus timezone stopped scheduling for EVERY watch
on the instance. It is now contained to the one watch, but that watch would
still silently never be checked again, so reject it at the boundary.
Returns None if valid, or an error message string if invalid.
"""
schedule = json_data.get('time_schedule_limit')
if not isinstance(schedule, dict):
return None
tz_name = schedule.get('timezone')
if not tz_name:
return None
if not isinstance(tz_name, str):
return "time_schedule_limit.timezone must be a string IANA timezone name, e.g. 'Europe/Berlin'."
from zoneinfo import available_timezones
if tz_name.strip() not in available_timezones():
return (f"time_schedule_limit.timezone '{tz_name}' is not a valid timezone name. "
f"Use an IANA name such as 'Europe/Berlin' or 'UTC'.")
return None
def validate_time_between_check_required(json_data):
"""
Validate that at least one time interval is specified when not using default settings.
@@ -159,6 +195,11 @@ class Watch(Resource):
if validation_error:
return validation_error, 400
# An invalid timezone here makes the watch permanently unschedulable
validation_error = validate_time_schedule_limit(request.json)
if validation_error:
return validation_error, 400
# Validate notification_urls if provided
if 'notification_urls' in request.json:
from wtforms import ValidationError
@@ -488,6 +529,11 @@ class CreateWatch(Resource):
if validation_error:
return validation_error, 400
# An invalid timezone here makes the watch permanently unschedulable
validation_error = validate_time_schedule_limit(json_data)
if validation_error:
return validation_error, 400
# Validate notification_urls if provided
if 'notification_urls' in json_data:
from wtforms import ValidationError
@@ -514,6 +560,12 @@ class CreateWatch(Resource):
del extras['url']
# PAGE_WATCH_LIMIT - checked up front so a blocked add is reported as 429 rather than
# being guessed at from add_watch() returning None
if self.datastore.watch_limit_reached():
current_watch_count = len(self.datastore.data['watching'])
return f"Watch limit reached ({current_watch_count}/{self.datastore.watch_limit} watches). Cannot add more watches.", 429
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
# Save processor config to separate JSON file
@@ -524,16 +576,6 @@ class CreateWatch(Resource):
# worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
return {'uuid': new_uuid}, 201
else:
# Check if it was a limit issue
page_watch_limit = os.getenv('PAGE_WATCH_LIMIT')
if page_watch_limit:
try:
page_watch_limit = int(page_watch_limit)
current_watch_count = len(self.datastore.data['watching'])
if current_watch_count >= page_watch_limit:
return f"Watch limit reached ({current_watch_count}/{page_watch_limit} watches). Cannot add more watches.", 429
except ValueError:
pass
return "Invalid or unsupported URL", 400
@auth.check_token
@@ -42,7 +42,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
system_default_browser=browser_config.system_default_description(datastore),
)
@add_watch_ui_blueprint.route("/snapshot", methods=['GET'])
@add_watch_ui_blueprint.route("/snapshot", methods=['POST'])
@login_optionally_required
def add_watch_ui_snapshot():
"""One-shot live fetch of an arbitrary URL for the Add Watch visual selector.
@@ -52,6 +52,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
connect, "Goto site", grab the screenshot + xpath element data, then tear
the browser down again. Element selection then happens client-side on the
returned data, exactly like the watch Edit page's visual selector.
POST-only and CSRF protected on purpose: this drives a real browser fetch and
writes a temporary watch dir, so as a GET it could be triggered cross-origin
(or by any tag/link that issues a GET) without the operator's consent.
"""
import base64
from changedetectionio.blueprint.browser_steps import (
@@ -71,7 +75,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# backslash/parser-differential rejection of GHSA-rph4-96w6-q594 (GHSA-56fq-63vj-9992).
# Note this fetch never reaches difference_detection_processor.call_browser(), so it gets
# no gating from there - it has to validate for itself.
url = (request.args.get('url') or '').strip()
url = (request.form.get('url') or '').strip()
ok, reason = is_fetch_url_allowed(url)
if not ok:
logger.warning(f"Add-watch snapshot: refused '{url}' - {reason}")
@@ -82,7 +86,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Either way it has to be able to render a preview - the plain HTTP client
# produces no screenshot and no element data, so previewing with it is pointless
# (and it used to be the silent default here, see the system-default bug).
fetcher_name = (request.args.get('fetch_backend') or '').strip() or browser_config.default_visual_browser(datastore)
fetcher_name = (request.form.get('fetch_backend') or '').strip() or browser_config.default_visual_browser(datastore)
if not fetcher_name or not browser_config.is_visual_capable(fetcher_name, datastore):
logger.warning(f"Add-watch snapshot: refused browser '{fetcher_name}' for '{url}'")
return make_response('No interactive browser available that can render a live preview '
@@ -59,9 +59,18 @@ $(document).ready(() => {
$.ajax({
url: add_watch_snapshot_url,
// POST, never GET - this makes the server-side browser fetch a URL of our
// choosing, so it must not be triggerable cross-origin. csrf.js adds the
// X-CSRFToken header to every non-GET ajax call; the CSRF field on the form
// is sent too so it works even if that handler hasn't run yet.
method: 'POST',
// Preview with the browser picked in the list - that same browser is what
// gets saved on the watch, so what you see here is what it will check with.
data: {url: url, fetch_backend: $('input[name="fetch_backend"]:checked').val() || ''},
data: {
url: url,
fetch_backend: $('input[name="fetch_backend"]:checked').val() || '',
csrf_token: $('#new-watch-form input[name="csrf_token"]').val() || '',
},
dataType: 'json',
}).done((data) => {
showState('ready');
@@ -98,7 +98,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
backup_threads = []
@backups_blueprint.route("/request-backup", methods=['GET'])
@backups_blueprint.route("/request-backup", methods=['POST'])
@login_optionally_required
def request_backup():
if any(thread.is_alive() for thread in backup_threads):
@@ -35,8 +35,10 @@
</p>
{% endif %}
<a class="pure-button pure-button-primary"
href="{{ url_for('backups.request_backup') }}">{{ _('Create backup') }}</a>
<form method="POST" action="{{ url_for('backups.request_backup') }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pure-button pure-button-primary">{{ _('Create backup') }}</button>
</form>
{% if available_backups %}
{# POST + CSRF token: this permanently deletes every backup archive, so it must
not be reachable from a bare GET (an <img src=...> on any page the operator
@@ -296,7 +296,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return browsersteps_start_session
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['POST'])
@login_optionally_required
def browsersteps_start_session():
# A new session was requested, return sessionID
@@ -100,7 +100,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
results = _recalc_check_status(uuid=uuid)
return results
@check_proxies_blueprint.route("/<uuid_str:uuid>/start", methods=['GET'])
@check_proxies_blueprint.route("/<uuid_str:uuid>/start", methods=['POST'])
@login_optionally_required
def start_check(uuid):
@@ -17,6 +17,7 @@ class Importer():
self.good = 0
self.remaining_data = []
self.import_profile = None
self.limit_reported = False
@abstractmethod
def run(self,
@@ -25,6 +26,21 @@ class Importer():
datastore):
pass
def watch_limit_hit(self, datastore, flash):
"""True once PAGE_WATCH_LIMIT leaves no room - the caller must then stop importing.
Asked before each add_watch() so the limit is reported once for the whole file.
add_watch() would otherwise flash the same error again for every remaining row.
"""
if not datastore.watch_limit_reached():
return False
if not self.limit_reported:
self.limit_reported = True
flash(datastore.watch_limit_message(), 'error')
return True
class import_url_list(Importer):
"""
@@ -44,7 +60,7 @@ class import_url_list(Importer):
if (len(urls) > 5000):
flash(gettext("Importing 5,000 of the first URLs from your list, the rest can be imported again."))
for url in urls:
for idx, url in enumerate(urls):
url = url.strip()
if not len(url):
continue
@@ -59,6 +75,12 @@ class import_url_list(Importer):
# Up to 5000 per batch so we dont flood the server
# @todo validators.url will fail when you add your own IP etc
if len(url) and 'http' in url.lower() and good < 5000:
if self.watch_limit_hit(datastore, flash):
# Hand back every line we didn't get to (originals, tags included) so they
# land in the textarea to retry once there's room
self.remaining_data.extend(u.strip() for u in urls[idx:] if u.strip())
break
extras = None
if processor:
extras = {'processor': processor}
@@ -107,6 +129,9 @@ class import_distill_io_json(Importer):
extras = {'title': d.get('name', None)}
if len(d['uri']) and good < 5000:
if self.watch_limit_hit(datastore, flash):
break
try:
# @todo we only support CSS ones at the moment
if d_config['selections'][0]['frames'][0]['excludes'][0]['type'] == 'css':
@@ -200,6 +225,9 @@ class import_xlsx_wachete(Importer):
# Don't bother processing anything else on this row
continue
if self.watch_limit_hit(datastore, flash):
break
new_uuid = datastore.add_watch(url=data['url'].strip(),
extras=extras,
tag=data.get('folder'),
@@ -281,6 +309,9 @@ class import_xlsx_custom(Importer):
# At minimum a URL is required.
if url:
if self.watch_limit_hit(datastore, flash):
break
new_uuid = datastore.add_watch(url=url,
extras=extras,
tag=tags,
+16
View File
@@ -0,0 +1,16 @@
"""Left-rail (action sidebar) display modes.
A leaf module on purpose: forms.py, flask_app.py and model/App.py all need these, and
model/App.py seeding its default from forms.py would drag the whole form stack (~550
modules) into the model layer.
"""
from flask_babel import lazy_gettext as _l
# The complete set of left-rail modes - flask_app.get_sidebar_mode_class() maps these
# (and only these) onto body classes, so a new mode here needs a new mapping there.
MENU_SIDEBAR_ACTIONMODES = [
('expandable', _l('Expand on hover')), # Slim icon rail that expands on hover/focus
('pinned-expanded', _l('Always expanded')), # Always expanded, never collapses
('minimal', _l('Stays minimal')), # Always small, never expands
]
MENU_SIDEBAR_ACTIONMODES_DEFAULT = 'expandable'
@@ -14,7 +14,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
price_data_follower_blueprint = Blueprint('price_data_follower', __name__)
@price_data_follower_blueprint.route("/<uuid_str:uuid>/accept", methods=['GET'])
@price_data_follower_blueprint.route("/<uuid_str:uuid>/accept", methods=['POST'])
@login_optionally_required
def accept(uuid):
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT
@@ -24,7 +24,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
return redirect(url_for("watchlist.index"))
@price_data_follower_blueprint.route("/<uuid_str:uuid>/reject", methods=['GET'])
@price_data_follower_blueprint.route("/<uuid_str:uuid>/reject", methods=['POST'])
@login_optionally_required
def reject(uuid):
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_REJECT
@@ -261,6 +261,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
llm_show_costs=llm_show_costs,
python_version=python_version,
uptime_seconds=uptime_seconds,
# None unless PAGE_WATCH_LIMIT is set, which hides the row entirely
watch_limit=datastore.watch_limit,
available_timezones=sorted(available_timezones()),
emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
extra_notification_token_placeholder_info=datastore.get_unique_notification_token_placeholders_available(),
@@ -276,7 +278,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return output
@settings_blueprint.route("/reset-api-key", methods=['GET'])
@settings_blueprint.route("/reset-api-key", methods=['POST'])
@login_optionally_required
def settings_reset_api_key():
secret = secrets.token_hex(16)
@@ -293,7 +295,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
logs=notification_debug_log if len(notification_debug_log) else ["Notification logs are empty - no notifications sent yet."])
return output
@settings_blueprint.route("/toggle-all-paused", methods=['GET'])
@settings_blueprint.route("/toggle-all-paused", methods=['POST'])
@login_optionally_required
def toggle_all_paused():
current_state = datastore.data['settings']['application'].get('all_paused', False)
@@ -307,7 +309,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return redirect(url_for('watchlist.index'))
@settings_blueprint.route("/toggle-all-muted", methods=['GET'])
@settings_blueprint.route("/toggle-all-muted", methods=['POST'])
@login_optionally_required
def toggle_all_muted():
current_state = datastore.data['settings']['application'].get('all_muted', False)
+1 -1
View File
@@ -130,7 +130,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
logger.exception("LLM model list full traceback:")
return jsonify({'models': [], 'error': str(e)}), 400
@llm_blueprint.route("/test", methods=['GET'])
@llm_blueprint.route("/test", methods=['POST'])
@login_optionally_required
def llm_test():
from flask import request
@@ -208,7 +208,7 @@ nav
</div>
</div>
<div class="pure-control-group">
<a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">{{ _('Regenerate API key') }}</a>
<button type="submit" formmethod="post" formnovalidate formaction="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">{{ _('Regenerate API key') }}</button>
</div>
<div class="pure-control-group">
<h4>{{ _('Chrome Extension') }}</h4>
@@ -391,6 +391,9 @@ nav
<div class="tab-pane-inner" id="info">
<p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>
{% if watch_limit is not none %}
<p><strong>{{ _('Maximum number of page watches:') }}</strong> {{ watch_limit }}</p>
{% endif %}
<p><strong>{{ _('Plugins active:') }}</strong></p>
{% if active_plugins %}
<ul>
@@ -577,7 +577,10 @@
if (mult.trim()) params.set('local_token_multiplier', mult.trim());
try {
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}?' + params);
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}?' + params, {
method: 'POST',
headers: {'X-CSRFToken': csrftoken}
});
const data = await resp.json();
if (data.ok) {
result.style.cssText = 'display:block; background:rgba(39,174,96,0.08); border:1px solid rgba(39,174,96,0.3); border-radius:5px; padding:0.6em 0.85em; font-size:0.88em; line-height:1.45;';
+4 -1
View File
@@ -62,7 +62,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/mute/<uuid_str:uuid>", methods=['GET'])
@tags_blueprint.route("/mute/<uuid_str:uuid>", methods=['POST'])
@login_optionally_required
def mute(uuid):
tag = datastore.data['settings']['application']['tags'].get(uuid)
@@ -189,6 +189,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
'watch': default,
'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
'llm_configured': bool(_get_llm_config(datastore)),
# Tells the shared AI include it is rendering a group, not a watch — `watch` above
# is this tag, so it cannot be used to tell the two apart.
'llm_group_edit': True,
}
included_content = {}
+18 -2
View File
@@ -10,6 +10,7 @@ from wtforms.fields.simple import BooleanField
from flask_babel import lazy_gettext as _l
from changedetectionio.blueprint.tags.colour import CSS_HEX_COLOUR_REGEX
from changedetectionio.widgets.ternary_boolean import TernaryNoneBooleanField
from changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form
from changedetectionio.llm.ui_strings import LLM_INTENT_TAG_PLACEHOLDER
from changedetectionio.llm.evaluator import (
@@ -28,7 +29,22 @@ class group_restock_settings_form(restock_settings_form):
validators=[validators.Optional(),
validators.Regexp(CSS_HEX_COLOUR_REGEX,
message=_l('Must be a hex colour, for example #4f8ef7'))])
llm_intent = TextAreaField('AI Change Intent',
# The group's one and only AI control. Same key as the per-watch switch (see forms.py) but
# ternary, because a group has a third useful answer: "no opinion, leave it to each watch"
# (the default). See tag_llm_decision() for the semantics of each state — #4204.
# @NOTE! In the near future this stops being a ternary bool and becomes a *profile*
# selector — pick one of the configured LLM profiles, or 'off', or inherit. The
# field name is already the future one; only the widget and the True/False/None
# value space need to change, so keep reads going through tag_llm_decision().
llm_backend_profile = TernaryNoneBooleanField(
_l('AI for watches in this group'),
default=None,
yes_text=_l('On, use the settings below'),
no_text=_l('Off for every watch'),
none_text=_l('Leave it to each watch'),
)
llm_intent = TextAreaField('AI Change Intent - Notify me when..',
validators=[validators.Optional(), validators.Length(max=2000)],
render_kw={"rows": "5", "placeholder": LLM_INTENT_TAG_PLACEHOLDER})
@@ -38,7 +54,7 @@ class group_restock_settings_form(restock_settings_form):
default='')
llm_change_summary_mode = RadioField(
_l('How this prompt combines with the inherited one'),
_l('Change Summary prompt - Append or Replace the default?'),
choices=[
(LLM_PROMPT_MODE_REPLACE, _l('Replace the inherited prompt')),
(LLM_PROMPT_MODE_APPEND, _l('Append to the inherited prompt')),
@@ -27,9 +27,9 @@
<div class="tabs collapsable">
<ul>
<li class="tab" id=""><a href="#general">{{ _('General') }}</a></li>
{% if llm_configured %}
{# Always shown, like the watch edit page: with no provider configured the pane
explains how to set one up (and carries the AI switches through on save). #}
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
{% endif %}
<li class="tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
{% if extra_tab_content %}
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
@@ -92,11 +92,9 @@
</fieldset>
</div>
{% if llm_configured %}
<div class="tab-pane-inner" id="ai-llm">
{% include "edit/include_llm_intent.html" %}
</div>
{% endif %}
<div class="tab-pane-inner" id="filters-and-triggers">
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
@@ -68,7 +68,10 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
{#-{{ loop.cycle('pure-table-odd', 'pure-table-even') }}-#}
<tr id="{{ uuid }}" class="">
<td class="watch-controls">
<a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}" aria-label="{{ _('Mute notifications') }}" title="{{ _('Mute notifications') }}"><i data-feather="{{ 'bell-off' if tag.notification_muted else 'bell' }}" class="icon icon-mute"></i></a>
<form method="POST" action="{{url_for('tags.mute', uuid=tag.uuid)}}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bare-btn link-mute state-{{'on' if tag.notification_muted else 'off'}}" aria-label="{{ _('Mute notifications') }}" title="{{ _('Mute notifications') }}"><i data-feather="{{ 'bell-off' if tag.notification_muted else 'bell' }}" class="icon icon-mute"></i></button>
</form>
</td>
<td class="watch-count">{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}" class="watch-tag-list tag-{{ tag.title|sanitize_tag_class }}">{{ tag.title }}</a></td>
+5 -2
View File
@@ -293,6 +293,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
uuid = list(datastore.data['watching'].keys()).pop()
new_uuid = datastore.clone(uuid)
if not new_uuid:
# Refused (e.g. PAGE_WATCH_LIMIT) - the reason is already flashed
return redirect(url_for('watchlist.index'))
if not datastore.data['watching'].get(uuid).get('paused'):
worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid}))
@@ -404,7 +407,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
return redirect(url_for('watchlist.index'))
@ui_blueprint.route("/share-url/<uuid_str:uuid>", methods=['GET'])
@ui_blueprint.route("/share-url/<uuid_str:uuid>", methods=['POST'])
@login_optionally_required
def form_share_put_watch(uuid):
"""Given a watch UUID, upload the info and return a share-link
@@ -452,7 +455,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
return redirect(url_for('watchlist.index'))
@ui_blueprint.route("/language/auto-detect", methods=['GET'])
@ui_blueprint.route("/language/auto-detect", methods=['POST'])
def delete_locale_language_session_var_if_it_exists():
"""Clear the session locale preference to auto-detect from browser Accept-Language header"""
if 'locale' in session:
+64 -16
View File
@@ -8,30 +8,57 @@ from jinja2 import Environment, FileSystemLoader
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio.time_handler import is_within_schedule
from changedetectionio.time_handler import default_timezone_name, is_within_schedule
from changedetectionio import worker_pool
from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates")
def _watch_tags(watch):
"""(uuid, tag) for this watch's tags, in its own tag order, skipping UUIDs we don't know."""
tags = datastore.data['settings']['application'].get('tags', {})
return [(tag_uuid, tags[tag_uuid]) for tag_uuid in watch.get('tags', []) if tag_uuid in tags]
def _resolve_llm_group_overrides(watch, datastore) -> dict:
"""
For each LLM field (llm_intent, llm_change_summary): if the watch has no own
value but a linked tag does, return {'value': ..., 'group_name': ...} so the
edit template can render the textarea as readonly with a group-sourced placeholder.
Returns None for each field when the watch has its own value (editable).
value but a linked group does, return {'value': ..., 'group_name': ..., 'group_uuid': ...}
so the edit template can show the inherited value as the textarea placeholder and link
back to the group that supplied it.
Returns None for each field when the watch has its own value (nothing inherited).
Only groups whose AI setting is "On" lend their prompts — the same gate the evaluator
applies via resolve_llm_field(), so the placeholder always reflects what will actually
run. See llm/evaluator.py:tag_llm_decision().
"""
result = {'llm_intent': None, 'llm_change_summary': None}
from changedetectionio.llm.evaluator import tag_llm_applies_to_watches, tag_llm_decision
result = {'llm_intent': None, 'llm_change_summary': None, 'llm_backend_profile': None}
# AI on/off is not a "fill in the blank" field: a group that has taken the decision
# (On or Off, i.e. not "leave it to each watch") decides for every watch in it (#4204),
# so report it and let the template show the watch's own checkbox as overridden.
for tag_uuid, tag in _watch_tags(watch):
if tag_llm_decision(tag) is not None:
result['llm_backend_profile'] = {
'value': tag_llm_decision(tag),
'group_name': tag.get('title', 'tag'),
'group_uuid': tag_uuid,
}
break
for field in ('llm_intent', 'llm_change_summary'):
if (watch.get(field) or '').strip():
continue # watch has its own value — editable, no group override
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag and (tag.get(field) or '').strip():
for tag_uuid, tag in _watch_tags(watch):
if not tag_llm_applies_to_watches(tag):
continue
if (tag.get(field) or '').strip():
result[field] = {
'value': tag.get(field).strip(),
'group_name': tag.get('title', 'tag'),
'group_uuid': tag_uuid,
}
break
return result
@@ -211,6 +238,16 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
extra_update_obj['filter_text_replaced'] = True
extra_update_obj['filter_text_removed'] = True
# A group that has taken the AI on/off decision owns that control, so the edit page
# renders it disabled (see include_llm_intent.html). A disabled checkbox isn't
# submitted at all, and for a checkbox "not submitted" is indistinguishable from
# "unticked" — so don't take this field from the form while a group decides. The
# watch keeps its own preference untouched, ready for when the group stops deciding.
# Resolved against the watch's *stored* tags — i.e. what the page was rendered from,
# so attaching or detaching a group in this same save is still honoured correctly.
if _resolve_llm_group_overrides(datastore.data['watching'][uuid], datastore).get('llm_backend_profile'):
extra_update_obj['llm_backend_profile'] = datastore.data['watching'][uuid].get('llm_backend_profile', True)
# Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs
tag_uuids = []
if form.data.get('tags'):
@@ -253,13 +290,14 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
watch = datastore.data['watching'].get(uuid)
if watch.get('time_between_check_use_default'):
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit') or {}
else:
time_schedule_limit = watch.get('time_schedule_limit')
time_schedule_limit = watch.get('time_schedule_limit') or {}
tz_name = time_schedule_limit.get('timezone')
if not tz_name:
tz_name = datastore.data['settings']['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC').strip())
tz_name = default_timezone_name(
time_schedule_limit.get('timezone')
or datastore.data['settings']['application'].get('scheduler_timezone_default')
)
if time_schedule_limit and time_schedule_limit.get('enabled'):
try:
@@ -267,9 +305,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
default_tz=tz_name
)
except Exception as e:
# Only decides whether to queue an immediate recheck — the watch is
# already saved by this point. Returning a bare `False` from a view
# made Flask raise TypeError and the save appeared to fail with a 500.
logger.error(
f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}")
return False
is_in_schedule = False
#############################
if not datastore.data['watching'][uuid].get('paused') and is_in_schedule:
@@ -469,15 +510,22 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
uuid = request.args.get('uuid','')
if datastore.data["watching"].get(uuid):
# Build the new list and REBIND it. Appending in place bypasses
# watch_base.__setitem__, so the watch is never flagged as edited and
# the "content unchanged since last check" skip stays active — the new
# ignore_text would then not take effect until the page changed on its
# own. Assigning the key marks the watch edited and forces reprocessing.
ignore_text = list(datastore.data["watching"][uuid]['ignore_text'])
if mode == 'exact':
for l in selection.splitlines():
datastore.data["watching"][uuid]['ignore_text'].append(l.strip())
ignore_text.append(l.strip())
elif mode == 'digit-regex':
for l in selection.splitlines():
# Replace any series of numbers with a regex
s = re.escape(l.strip())
s = re.sub(r'[0-9]+', r'\\d+', s)
datastore.data["watching"][uuid]['ignore_text'].append('/' + s + '/')
ignore_text.append('/' + s + '/')
datastore.data["watching"][uuid]['ignore_text'] = ignore_text
# Save the updated ignore_text
datastore.data["watching"][uuid].commit()
@@ -250,7 +250,7 @@ Math: {{ 1 + 1 }}") }}
<div class="flex-wrapper" >
<div id="browser-steps-ui" class="noselect">
<div class="noselect" id="browsersteps-selector-wrapper" style="width: 100%">
<div class="noselect" id="browsersteps-selector-wrapper">
<span class="loader" >
<span id="browsersteps-click-start">
<h2 >{{ _('Click here to Start') }}</h2>
@@ -18,6 +18,24 @@ from changedetectionio.blueprint.watchlist.row_context import watch_row_context
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
watchlist_blueprint = Blueprint('watchlist', __name__, template_folder="templates")
@watchlist_blueprint.route("/toggle", methods=['POST'])
@login_optionally_required
def toggle():
op = request.args.get('op')
uuid = request.args.get('uuid')
watch = datastore.data['watching'].get(uuid)
if not watch:
flash(_('Watch not found'), 'error')
else:
if op == 'pause':
watch.toggle_pause()
elif op == 'mute':
watch.toggle_mute()
watch.commit()
return redirect(url_for('watchlist.index', tag=request.args.get('tag')))
@watchlist_blueprint.route("/", methods=['GET'])
@login_optionally_required
def index():
@@ -36,17 +54,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
if request.args.get('rss'):
return redirect(url_for('rss.feed', tag=active_tag_uuid))
op = request.args.get('op')
if op:
uuid = request.args.get('uuid')
if op == 'pause':
datastore.data['watching'][uuid].toggle_pause()
elif op == 'mute':
datastore.data['watching'][uuid].toggle_mute()
datastore.data['watching'][uuid].commit()
return redirect(url_for('watchlist.index', tag = active_tag_uuid))
# Sort by last_changed and add the uuid which is usually the key..
sorted_watches = []
active_processor = request.args.get('processor', '').strip()
@@ -35,10 +35,12 @@
<td class="inline checkbox-uuid" ><div><input name="uuids" type="checkbox" value="{{ watch.uuid}} " >{# <span class="counter-i">{{ loop.index+pagination.skip }}</span>#}</div></td>
<td class="inline watch-controls">
<div>
<a class="ajax-op state-off pause-toggle" data-op="pause" aria-label="{{ _('Pause checks') }}" title="{{ _('Pause checks') }}" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="pause" class="icon icon-pause"></i></a>
<a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" aria-label="{{ _('UnPause checks') }}" title="{{ _('UnPause checks') }}" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="play" class="icon icon-unpause"></i></a>
<a class="ajax-op state-off mute-toggle" data-op="mute" aria-label="{{ _('Mute notification') }}" title="{{ _('Mute notification') }}" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="bell" class="icon icon-mute"></i></a>
<a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" aria-label="{{ _('UnMute notification') }}" title="{{ _('UnMute notification') }}" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="bell-off" class="icon icon-mute"></i></a>
{%- set pause_action = url_for('watchlist.toggle', op='pause', uuid=watch.uuid, tag=active_tag_uuid) -%}
{%- set mute_action = url_for('watchlist.toggle', op='mute', uuid=watch.uuid, tag=active_tag_uuid) -%}
<button type="submit" formmethod="post" formaction="{{ pause_action }}" class="bare-btn ajax-op state-off pause-toggle" data-op="pause" aria-label="{{ _('Pause checks') }}" title="{{ _('Pause checks') }}"><i data-feather="pause" class="icon icon-pause"></i></button>
<button type="submit" formmethod="post" formaction="{{ pause_action }}" class="bare-btn ajax-op state-on pause-toggle" data-op="pause" style="display: none" aria-label="{{ _('UnPause checks') }}" title="{{ _('UnPause checks') }}"><i data-feather="play" class="icon icon-unpause"></i></button>
<button type="submit" formmethod="post" formaction="{{ mute_action }}" class="bare-btn ajax-op state-off mute-toggle" data-op="mute" aria-label="{{ _('Mute notification') }}" title="{{ _('Mute notification') }}"><i data-feather="bell" class="icon icon-mute"></i></button>
<button type="submit" formmethod="post" formaction="{{ mute_action }}" class="bare-btn ajax-op state-on mute-toggle" data-op="mute" style="display: none" aria-label="{{ _('UnMute notification') }}" title="{{ _('UnMute notification') }}"><i data-feather="bell-off" class="icon icon-mute"></i></button>
</div>
</td>
@@ -81,13 +83,13 @@
<div class="error-text" style="display:none;">{{ error_texts|safe }}</div>
{%- if watch['processor'] == 'text_json_diff' -%}
{%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%}
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <button type="submit" formmethod="post" formaction="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</button> <button type="submit" formmethod="post" formaction="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="bare-btn bare-btn--link">No</button></div>
{%- endif -%}
{%- endif -%}
</div>
<div class="status-icons">
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="{{ _('Create a link to share watch config with others') }}" ></a>
<button type="submit" formmethod="post" formaction="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}" class="bare-btn link-spread"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="{{ _('Create a link to share watch config with others') }}" ></button>
{%- set effective_fetcher = watch.get_fetch_backend if watch.get_fetch_backend != "system" else system_default_fetcher -%}
{%- if effective_fetcher and ("html_webdriver" in effective_fetcher or "html_" in effective_fetcher or "extra_browser_" in effective_fetcher) -%}
{{ effective_fetcher|fetcher_status_icons }}
@@ -163,7 +165,9 @@
<div>
{%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%}
<a href="" class="already-in-queue-button recheck cdio-btn cdio-btn--primary cdio-btn--sm" style="display: none;" disabled="disabled"><i data-feather="clock"></i>{{ _('Queued') }}</a>
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck cdio-btn cdio-btn--primary cdio-btn--sm"><i data-feather="refresh-cw"></i>{{ _('Recheck') }}</a>
<button type="submit" formmethod="post"
formaction="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}"
data-op='recheck' class="ajax-op recheck cdio-btn cdio-btn--primary cdio-btn--sm"><i data-feather="refresh-cw"></i>{{ _('Recheck') }}</button>
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="cdio-btn cdio-btn--primary cdio-btn--sm">{{ _('Edit') }}</a>
<a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="cdio-btn cdio-btn--primary cdio-btn--sm history-link ai-history-btn" style="display: none;" data-uuid="{{ watch.uuid }}" data-summary-url="{{ url_for('ui.ui_diff.diff_llm_summary', uuid=watch.uuid) }}" data-processor-data-url="{{ url_for('ui.ui_diff.diff_history_page_processor_data', uuid=watch.uuid) }}"><span class="btn-label-history">{{ _('History') }}</span><span class="btn-label-summary">&#x2728; {{ _('Summary') }}</span></a>
<a href="{{ url_for('ui.ui_preview.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="cdio-btn cdio-btn--primary cdio-btn--sm preview-link" style="display: none;">{{ _('Preview') }}</a>
+17 -3
View File
@@ -34,7 +34,13 @@ CUSTOM_OPERATIONS = {
def filter_complete_rules(ruleset):
rules = [
rule for rule in ruleset
if all(value not in ("", False, "None", None) for value in [rule["operator"], rule["field"], rule["value"]])
if all(
rule.get(k) is not None
and rule.get(k) is not False
and rule.get(k) != ""
and rule.get(k) != "None"
for k in ("operator", "field", "value")
)
]
return rules
@@ -54,12 +60,20 @@ def convert_to_jsonlogic(logic_operator: str, rule_dict: list):
field = condition["field"]
value = condition["value"]
if not operator or operator == 'None' or not value or not field:
if (
not operator
or operator == 'None'
or not field
or value is None
or value is False
or value == ""
or value == "None"
):
raise EmptyConditionRuleRowNotUsable()
# Convert value to int/float if possible
try:
if isinstance(value, str) and "." in value and str != "None":
if isinstance(value, str) and "." in value and value != "None":
value = float(value)
else:
value = int(value)
@@ -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']
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -4,6 +4,7 @@ from loguru import logger
from wtforms.widgets.core import TimeInput
from flask_babel import lazy_gettext as _l, gettext
from changedetectionio.blueprint.menu_modes import MENU_SIDEBAR_ACTIONMODES, MENU_SIDEBAR_ACTIONMODES_DEFAULT
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_TEMPLATE_TYPE_OPTIONS, RSS_TEMPLATE_HTML_DEFAULT
from changedetectionio.llm.ui_strings import LLM_INTENT_WATCH_PLACEHOLDER
from changedetectionio.llm.evaluator import (
@@ -696,14 +697,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)})
@@ -953,7 +954,7 @@ class processor_text_json_diff_form(commonSettingsForm):
time_between_check_use_default = BooleanField(_l('Use global settings for time between check and scheduler.'), default=False)
llm_intent = TextAreaField(_l('AI Change Intent'), validators=[validators.Optional(), validators.Length(max=2000)],
llm_intent = TextAreaField(_l('AI Change Intent - Notify me when..'), validators=[validators.Optional(), validators.Length(max=2000)],
render_kw={"rows": "5", "placeholder": LLM_INTENT_WATCH_PLACEHOLDER})
llm_change_summary = TextAreaField(_l('AI Change Summary'), validators=[validators.Optional(), validators.Length(max=2000)],
@@ -961,13 +962,16 @@ class processor_text_json_diff_form(commonSettingsForm):
default='')
llm_change_summary_mode = RadioField(
_l('How this prompt combines with the inherited one'),
_l('Change Summary prompt - Append or Replace the default?'),
choices=[
(LLM_PROMPT_MODE_REPLACE, _l('Replace the inherited prompt')),
(LLM_PROMPT_MODE_APPEND, _l('Append to the inherited prompt')),
],
default=LLM_PROMPT_MODE_REPLACE,
)
# @NOTE! In the near future you should be able to select which LLM profile *OR* "off"/None for this watch/group
# For now we use the 'future' field naming but keep the functionality simple.
llm_backend_profile = BooleanField(_l('AI enabled for this watch?'), default=True)
include_filters = StringListField(_l('CSS/JSONPath/JQ/XPath Filters'), [ValidateCSSJSONXPATHInput()], default='')
@@ -1161,9 +1165,8 @@ class globalSettingsApplicationUIForm(Form):
choices=[('long', _l('Long (1 minute ago)')), ('short', _l('Short (1m ago)'))],
default='long', validators=[validators.Optional()])
sidebar_mode = SelectField(_l('Navigation sidebar'),
choices=[('collapsed', _l('Collapsed icon rail (expands on hover)')),
('pinned', _l('Always expanded'))],
default='collapsed', validators=[validators.Optional()])
choices=MENU_SIDEBAR_ACTIONMODES,
default=MENU_SIDEBAR_ACTIONMODES_DEFAULT, validators=[validators.Optional()])
# datastore.data['settings']['application']..
class globalSettingsApplicationForm(commonSettingsForm):
+10 -7
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)
@@ -508,11 +505,17 @@ def _has_lone_surrogate(value):
return any(_has_lone_surrogate(v) for v in value)
return False
def _sanitize_lone_surrogate_str(value: str) -> str:
return _LONE_SURROGATE_RE.sub('\ufffd', value)
def _sanitize_lone_surrogates(value):
if isinstance(value, str):
return _LONE_SURROGATE_RE.sub('\ufffd', value)
return _sanitize_lone_surrogate_str(value)
if isinstance(value, dict):
return {_sanitize_lone_surrogates(k): _sanitize_lone_surrogates(v) for k, v in value.items()}
# JSON object keys are always strings, so they only need the substitution - recursing on a
# key would (in theory) hand back an unhashable dict/list
return {_sanitize_lone_surrogate_str(k) if isinstance(k, str) else k: _sanitize_lone_surrogates(v)
for k, v in value.items()}
if isinstance(value, list):
return [_sanitize_lone_surrogates(v) for v in value]
return value
@@ -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
+61 -26
View File
@@ -6,6 +6,7 @@ and makes the call easy to mock in tests.
import logging
import os
from loguru import logger
# Default output token cap for JSON-returning calls (intent eval, preview, setup).
@@ -24,6 +25,9 @@ DEFAULT_TIMEOUT = int(os.getenv('LLM_TIMEOUT', 300))
# longer deadline (Hermes-style, 30 min). Overridable via LLM_LOCAL_TIMEOUT; see
# evaluator.resolve_llm_timeout() for how the endpoint is classified.
DEFAULT_LOCAL_TIMEOUT = int(os.getenv('LLM_LOCAL_TIMEOUT', 1800))
# Models and reasoning architectures that reject explicit sampling parameters (temperature/top_p)
_NO_TEMPERATURE_MODEL_KEYWORDS = ('flash-lite', 'thinking-exp', 'o1', 'o3', 'o4')
DEFAULT_RETRIES = 3
@@ -63,10 +67,16 @@ def _install_litellm_debug():
logger.info("LLM client: litellm debug logging routed through loguru")
def completion(model: str, messages: list, api_key: str = None,
api_base: str = None, timeout: int = DEFAULT_TIMEOUT,
max_tokens: int = None, extra_body: dict = None,
debug: bool = False) -> tuple[str, int, int, int]:
def completion( # noqa: C901
model: str,
messages: list,
api_key: str = None,
api_base: str = None,
timeout: int = DEFAULT_TIMEOUT,
max_tokens: int = None,
extra_body: dict = None,
debug: bool = False,
) -> tuple[str, int, int, int]:
"""
Call the LLM and return (response_text, total_tokens, input_tokens, output_tokens).
Retries up to DEFAULT_RETRIES times on timeout or connection errors.
@@ -79,7 +89,7 @@ def completion(model: str, messages: list, api_key: str = None,
try:
import litellm
except ImportError:
raise RuntimeError("litellm is not installed. Add it to requirements.txt.")
raise RuntimeError("litellm is not installed. Add it to requirements.txt.") from None
if debug:
_install_litellm_debug()
@@ -90,9 +100,12 @@ def completion(model: str, messages: list, api_key: str = None,
'model': model,
'messages': messages,
'timeout': _timeout,
'temperature': 0,
'max_tokens': max_tokens if max_tokens is not None else _MAX_COMPLETION_TOKENS,
}
_m_lower = (model or '').lower()
if not any(k in _m_lower for k in _NO_TEMPERATURE_MODEL_KEYWORDS):
kwargs['temperature'] = 0
if api_key:
kwargs['api_key'] = api_key
if api_base:
@@ -122,9 +135,9 @@ def completion(model: str, messages: list, api_key: str = None,
attempt += 1
try:
response = litellm.completion(**kwargs)
choice = response.choices[0]
message = choice.message
finish = getattr(choice, 'finish_reason', None)
choice = response.choices[0]
message = choice.message
finish = getattr(choice, 'finish_reason', None)
text = message.content or ''
@@ -133,7 +146,9 @@ def completion(model: str, messages: list, api_key: str = None,
parts = getattr(message, 'parts', None)
if parts:
text = ''.join(getattr(p, 'text', '') or '' for p in parts).strip()
logger.debug(f"LLM client: extracted text from message.parts ({len(parts)} parts) model={model!r}")
logger.debug(
f"LLM client: extracted text from message.parts ({len(parts)} parts) model={model!r}"
)
if finish == 'length':
logger.warning(
@@ -149,9 +164,13 @@ def completion(model: str, messages: list, api_key: str = None,
)
usage = getattr(response, 'usage', None)
input_tokens = int(getattr(usage, 'prompt_tokens', 0) or 0) if usage else 0
input_tokens = int(getattr(usage, 'prompt_tokens', 0) or 0) if usage else 0
output_tokens = int(getattr(usage, 'completion_tokens', 0) or 0) if usage else 0
total_tokens = int(getattr(usage, 'total_tokens', 0) or 0) if usage else (input_tokens + output_tokens)
total_tokens = (
int(getattr(usage, 'total_tokens', 0) or 0)
if usage
else (input_tokens + output_tokens)
)
logger.debug(
f"LLM client: model={model!r} finish={finish!r} "
f"tokens={total_tokens} (in={input_tokens} out={output_tokens}) "
@@ -181,21 +200,37 @@ def completion(model: str, messages: list, api_key: str = None,
raise
except litellm.BadRequestError as e:
# If the provider rejected an unsupported sampling param (and we haven't
# already stripped them), drop them and retry once. attempt-=1 keeps this
# off the timeout-retry budget; _stripped_sampling prevents a loop.
msg = str(e).lower()
if (not _stripped_sampling
and any(p in kwargs for p in _sampling_params)
and any(p in msg for p in _sampling_params)):
# If the provider rejected an unsupported sampling param or extra_body
# (e.g. Gemini INVALID_ARGUMENT on thinkingConfig or temperature), drop
# them and retry once.
if not _stripped_sampling:
dropped = [p for p in _sampling_params if kwargs.pop(p, None) is not None]
_stripped_sampling = True
attempt -= 1
logger.warning(
f"LLM client: model={model!r} rejected sampling params {dropped} "
f"({e}); retrying without them"
)
continue
extra_body = kwargs.get('extra_body')
if isinstance(extra_body, dict):
gen_cfg = extra_body.get('generationConfig')
if (
isinstance(gen_cfg, dict)
and gen_cfg.pop('thinkingConfig', None) is not None
):
dropped.append('thinkingConfig')
if not gen_cfg:
extra_body.pop('generationConfig', None)
if not extra_body:
kwargs.pop('extra_body', None)
elif 'thinkingConfig' in extra_body:
extra_body.pop('thinkingConfig', None)
dropped.append('thinkingConfig')
if not extra_body:
kwargs.pop('extra_body', None)
if dropped:
_stripped_sampling = True
attempt -= 1
logger.warning(
f"LLM client: model={model!r} rejected request ({e}); "
f"stripped {dropped} and retrying once"
)
continue
logger.warning(f"LLM call failed: model={model!r} error={e}")
raise
+83 -21
View File
@@ -5,7 +5,8 @@ Two public entry points:
- run_setup(watch, datastore) — one-time: decide if pre-filter needed
- evaluate_change(watch, datastore, diff, current_snapshot) — per-change evaluation
Intent resolution: watch.llm_intent → first tag with llm_intent → None (no evaluation)
Intent resolution: watch.llm_intent → first tag with llm_intent whose AI switch is "on"
(see tag_llm_applies_to_watches) → None (no evaluation)
Cache: each (intent, diff) pair is evaluated exactly once, result stored in watch.
Environment variable overrides (take priority over datastore settings):
@@ -97,6 +98,8 @@ def _thinking_extra_body(model: str, budget: int) -> dict | None:
"""
if not model.startswith('gemini/'):
return None
if 'flash-lite' in model.lower():
return None
try:
import litellm
if not litellm.get_model_info(model).get('supports_reasoning'):
@@ -244,6 +247,70 @@ def resolve_llm_timeout(llm_cfg: dict) -> int:
# Intent resolution
# ---------------------------------------------------------------------------
# A group/tag has exactly one AI control (`llm_backend_profile`), and it is ternary:
#
# True — AI on for every watch in the group, using the group's AI settings
# (llm_intent / llm_change_summary cascade down to the watches)
# False — AI off for every watch in the group; its prompts are stored but never used
# None — the group has no opinion: each watch's own AI switch and prompts apply
#
# On a *watch* the same key is a plain bool (on/off, default on). One control per level, so
# there is nothing to reconcile between an "override?" flag and an "enabled?" flag.
def tag_llm_decision(tag):
"""This group's AI decision: True (on, use its settings), False (off), or None (no opinion)."""
if not tag:
return None
value = tag.get('llm_backend_profile')
return None if value is None else bool(value)
def tag_llm_applies_to_watches(tag) -> bool:
"""True when this group hands its AI settings down to its watches.
Only the "on" state does that: a group set to "off" suppresses AI for its watches rather
than lending them prompts, and a group with no opinion leaves them alone entirely. This is
the single gate behind both the evaluator cascade and the watch edit page's
"From group ..." placeholder.
"""
return tag_llm_decision(tag) is True
def _watch_tags(watch, datastore):
"""Yield this watch's tag dicts, in the watch's own tag order, skipping unknown UUIDs."""
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag:
yield tag
def _tags_applying_llm(watch, datastore):
"""Yield this watch's groups, in order, that hand their AI settings to their watches."""
for tag in _watch_tags(watch, datastore):
if tag_llm_applies_to_watches(tag):
yield tag
def llm_enabled_for_watch(watch, datastore) -> tuple[bool, str]:
"""Is automatic AI evaluation switched on for this watch? Returns (enabled, source).
See #4204 — users with hundreds of watches want AI on only a select few.
A group with an opinion decides for all of its watches ("the group setting overrides any
watch on/off"), so the first such group wins over the watch's own switch; groups set to
"leave it to each watch" are skipped. With no group deciding, the watch decides — and a
missing key means on, so watches predating this switch keep working.
Only gates *automatic* spend (the worker's intent/summary passes and the restock AI
plugin). Explicit user actions — the diff page "Summary" button, the intent preview —
stay available, since those cost tokens only when someone deliberately clicks.
"""
for tag in _watch_tags(watch, datastore):
decision = tag_llm_decision(tag)
if decision is not None:
return decision, tag.get('title', 'tag')
return bool(watch.get('llm_backend_profile', True)), 'watch'
def resolve_llm_field(watch, datastore, field: str) -> tuple[str, str]:
"""
Generic cascade resolver for any LLM per-watch field.
@@ -254,12 +321,10 @@ def resolve_llm_field(watch, datastore, field: str) -> tuple[str, str]:
if value:
return value, 'watch'
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag:
tag_value = (tag.get(field) or '').strip()
if tag_value:
return tag_value, tag.get('title', 'tag')
for tag in _tags_applying_llm(watch, datastore):
tag_value = (tag.get(field) or '').strip()
if tag_value:
return tag_value, tag.get('title', 'tag')
return '', ''
@@ -273,12 +338,10 @@ def resolve_intent(watch, datastore) -> tuple[str, str]:
if intent:
return intent, 'watch'
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag:
tag_intent = (tag.get('llm_intent') or '').strip()
if tag_intent:
return tag_intent, tag.get('title', 'tag')
for tag in _tags_applying_llm(watch, datastore):
tag_intent = (tag.get('llm_intent') or '').strip()
if tag_intent:
return tag_intent, tag.get('title', 'tag')
return '', ''
@@ -549,15 +612,14 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
def _first_tag_with_field(watch, datastore, field: str):
"""Return (value, tag) for the first linked tag with a non-empty `field`, else ('', None).
Same first-match-wins order as resolve_llm_field(); this variant also hands back the
tag itself so the caller can read sibling keys such as the prompt mode.
Same first-match-wins order as resolve_llm_field() (so only groups opted in via
tag_llm_applies_to_watches() count); this variant also hands back the tag itself so
the caller can read sibling keys such as the prompt mode.
"""
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag:
value = (tag.get(field) or '').strip()
if value:
return value, tag
for tag in _tags_applying_llm(watch, datastore):
value = (tag.get(field) or '').strip()
if value:
return value, tag
return '', None
+19 -13
View File
@@ -9,39 +9,44 @@ from .bm25_trim import trim_to_relevant
_AGO_RE = re.compile(r'^\d+\s+\w+\s+ago$', re.IGNORECASE)
SNAPSHOT_CONTEXT_CHARS = 30_000 # current page state excerpt sent alongside the diff
SNAPSHOT_CONTEXT_CHARS = 30_000 # current page state excerpt sent alongside the diff
def _annotate_moved_lines(diff_text: str) -> str:
"""
Pre-process a unified diff to mark lines that appear on both the + and - sides
as [MOVED] rather than genuinely added/removed. This prevents the LLM from
as [MOVED] (~ prefix) rather than genuinely added/removed. This prevents the LLM from
incorrectly classifying repositioned content as new or deleted.
Also marks standalone relative timestamps (e.g. '3 hours ago') as ~ trivial.
Lines are compared after stripping leading +/- and whitespace so that
indentation changes don't prevent matching.
"""
lines = diff_text.splitlines()
added_texts = {l[1:].strip().lower() for l in lines if l.startswith('+') and l[1:].strip()}
removed_texts = {l[1:].strip().lower() for l in lines if l.startswith('-') and l[1:].strip()}
moved_texts = added_texts & removed_texts
if not moved_texts:
return diff_text
added_texts = {
line[1:].strip().lower() for line in lines if line.startswith('+') and line[1:].strip()
}
removed_texts = {
line[1:].strip().lower() for line in lines if line.startswith('-') and line[1:].strip()
}
moved_texts = added_texts & removed_texts
result = []
has_changes = False
for line in lines:
if line.startswith(('+', '-')):
bare = line[1:].strip().lower()
if bare in moved_texts or _AGO_RE.match(line[1:].strip()):
result.append(f'~{line[1:]}') # ~ prefix = moved/reordered/trivial, skip
has_changes = True
continue
result.append(line)
return '\n'.join(result)
return '\n'.join(result) if has_changes else diff_text
def build_eval_prompt(intent: str, diff: str, current_snapshot: str = '',
url: str = '', title: str = '') -> str:
def build_eval_prompt(
intent: str, diff: str, current_snapshot: str = '', url: str = '', title: str = ''
) -> str:
"""
Build the user message for a diff evaluation call.
The system prompt is kept separate (see build_eval_system_prompt).
@@ -131,8 +136,9 @@ def build_preview_system_prompt() -> str:
)
def build_change_summary_prompt(diff: str, custom_prompt: str,
current_snapshot: str = '', url: str = '', title: str = '') -> str:
def build_change_summary_prompt(
diff: str, custom_prompt: str, current_snapshot: str = '', url: str = '', title: str = ''
) -> str:
"""
Build the user message for an AI Change Summary call.
The user supplies their own instructions (custom_prompt); this wraps them
+46 -6
View File
@@ -9,16 +9,56 @@ text. This module handles those cases gracefully.
import json
import re
from changedetectionio.strtobool import strtobool
# Positional selectors are fragile — reject them even if the LLM generates them
_POSITIONAL_SELECTOR_RE = re.compile(
r'nth-child|nth-of-type|:eq\(|\[\d+\]|\/\/\*\[\d',
re.IGNORECASE
r'nth-child|nth-of-type|:eq\(|\[\d+\]|\/\/\*\[\d', re.IGNORECASE
)
# Reasoning models (DeepSeek-R1, Qwen reasoning, etc.) wrap their scratchpad in <think> tags.
# Three shapes have to be handled, because the scratchpad routinely contains JSON of its own
# ("initially I thought {"important": false}, but..."), so leaving any of it in place lets
# _extract_json lock onto a discarded intermediate answer instead of the real one.
_THINK_BLOCK_RE = re.compile(r'<think(?:ing)?>.*?</think(?:ing)?>', re.DOTALL | re.IGNORECASE)
_THINK_TAIL_RE = re.compile(r'^.*</think(?:ing)?>', re.DOTALL | re.IGNORECASE)
_THINK_OPEN_RE = re.compile(r'<think(?:ing)?>', re.IGNORECASE)
def _to_bool(value, default: bool = False) -> bool:
"""Safely coerce boolean values from LLM responses.
Handles native booleans, truthy/falsy integers (1/0), and string booleans
("true", "false", "yes", "no", "1", "0") using strtobool.
Avoids Python's bool("false") -> True bug on stringified JSON booleans.
"""
if value is None:
return default
try:
return strtobool(value)
except (ValueError, AttributeError):
return default
def _extract_json(raw: str) -> str:
"""Strip markdown fences and extract the first JSON object."""
"""Strip reasoning blocks, markdown fences, and extract the first JSON object.
Raises:
ValueError: the response opens a reasoning block it never closes, i.e. it was cut
off mid-thought (usually by max_tokens) and contains no answer at all. Callers
in evaluator.py catch this and fall back safely - for diff evaluation that
means passing the change through as important rather than silently dropping it.
"""
raw = raw.strip()
# Well-formed scratchpads.
raw = _THINK_BLOCK_RE.sub('', raw).strip()
# Some providers/chat templates emit the opening tag themselves and only the closer comes
# back over the wire, so anything up to the last closer is still scratchpad.
raw = _THINK_TAIL_RE.sub('', raw).strip()
# An opener with no closer means the response was truncated part-way through reasoning.
# There is no answer to find; the only JSON present would be a discarded intermediate one.
if _THINK_OPEN_RE.search(raw):
raise ValueError('LLM response contains an unterminated reasoning block (truncated?)')
# Remove ```json ... ``` or ``` ... ``` fences
raw = re.sub(r'^```(?:json)?\s*', '', raw, flags=re.MULTILINE)
raw = re.sub(r'\s*```$', '', raw, flags=re.MULTILINE)
@@ -36,7 +76,7 @@ def parse_eval_response(raw: str) -> dict:
try:
data = json.loads(_extract_json(raw))
return {
'important': bool(data.get('important', False)),
'important': _to_bool(data.get('important'), default=False),
'summary': str(data.get('summary', '')).strip(),
}
except (json.JSONDecodeError, AttributeError):
@@ -52,7 +92,7 @@ def parse_preview_response(raw: str) -> dict:
try:
data = json.loads(_extract_json(raw))
return {
'found': bool(data.get('found', False)),
'found': _to_bool(data.get('found'), default=False),
'answer': str(data.get('answer', '')).strip(),
}
except (json.JSONDecodeError, AttributeError):
@@ -67,7 +107,7 @@ def parse_setup_response(raw: str) -> dict:
"""
try:
data = json.loads(_extract_json(raw))
needs = bool(data.get('needs_prefilter', False))
needs = _to_bool(data.get('needs_prefilter'), default=False)
selector = data.get('selector') or None
# Sanitise: reject positional selectors
+2 -1
View File
@@ -1,6 +1,7 @@
from os import getenv
from copy import deepcopy
from changedetectionio.blueprint.menu_modes import MENU_SIDEBAR_ACTIONMODES_DEFAULT
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_CONTENT_FORMAT_DEFAULT
from changedetectionio.model.Tags import TagsDict
@@ -80,7 +81,7 @@ class model(dict):
'socket_io_enabled': True,
'favicons_enabled': True,
'timeago_format': 'long', # 'long' = "1 minute ago", 'short' = "1m ago"
'sidebar_mode': 'collapsed', # 'collapsed' = slim icon rail, expands on hover; 'pinned' = always expanded
'sidebar_mode': MENU_SIDEBAR_ACTIONMODES_DEFAULT, # one of blueprint.menu_modes.MENU_SIDEBAR_ACTIONMODES
},
}
}
+4
View File
@@ -46,6 +46,10 @@ class model(EntityPersistenceMixin, watch_base):
super(model, self).__init__(*arg, **kw)
self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')
# Ternary on a tag, unlike the plain bool on a watch: None ("leave it to each watch")
# is the default, so a group never touches its watches' AI until explicitly set.
# See llm/evaluator.py:tag_llm_decision().
self['llm_backend_profile'] = kw.get('default', {}).get('llm_backend_profile', None)
self['url_match_pattern'] = kw.get('default', {}).get('url_match_pattern', '')
if kw.get('default'):
+9 -8
View File
@@ -184,16 +184,11 @@ class watch_base(dict):
'check_count': 0,
'check_unique_lines': False, # On change-detected, compare against all history if its something new
'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine.
# LLM intent-based evaluation
'content-type': None,
'date_created': None,
'extract_lines_containing': [], # Keep only lines containing these substrings (plain text, case-insensitive)
'extract_text': [], # Extract text by regex after filters
# LLM intent-based evaluation
'llm_intent': '', # Plain-English description of what the user cares about (change filter)
'llm_change_summary': '', # Prompt for AI Change Summary — replaces {{ diff }} in notifications
'llm_change_summary_mode': 'replace', # 'replace' the inherited prompt, or 'append' to it
'llm_prefilter': None, # CSS selector derived at setup time (semantic only, e.g. "footer")
'llm_evaluation_cache': {}, # {sha256(intent+diff): {important, summary}} - evaluated once, cached
'fetch_backend': 'system', # plaintext, playwright etc
'fetch_time': 0.0,
'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),
@@ -202,16 +197,22 @@ class watch_base(dict):
'filter_text_replaced': True,
'follow_price_changes': True,
'has_ldjson_price_data': None,
'history_snapshot_max_length': None,
'headers': {}, # Extra headers to send
'ignore_text': [], # List of text to ignore when calculating the comparison checksum
'history_snapshot_max_length': None,
'ignore_status_codes': None,
'ignore_text': [], # List of text to ignore when calculating the comparison checksum
'in_stock_only': True, # Only trigger change on going to instock from out-of-stock
'include_filters': [],
'last_checked': 0,
'last_error': False,
'last_notification_error': None,
'last_viewed': 0, # history key value of the last viewed via the [diff] link
'llm_backend_profile': True, # @note - now its just a bool but in the near future we can select a LLM profile or 'off'/false
'llm_change_summary': '', # Prompt for AI Change Summary — replaces {{ diff }} in notifications
'llm_change_summary_mode': 'replace', # 'replace' the inherited prompt, or 'append' to it
'llm_evaluation_cache': {}, # {sha256(intent+diff): {important, summary}} - evaluated once, cached
'llm_intent': '', # Plain-English description of what the user cares about (change filter)
'llm_prefilter': None, # CSS selector derived at setup time (semantic only, e.g. "footer")
'method': 'GET',
'notification_alert_count': 0,
'notification_body': None,
+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
@@ -506,8 +506,11 @@ class perform_site_check(difference_detection_processor):
# Try plugin override - plugins can decide if they support this fetcher
if fetcher_name:
logger.debug(f"Calling extra plugins for getting item price/availability (fetcher: {fetcher_name})")
from changedetectionio.llm.evaluator import resolve_intent
_llm_intent, _ = resolve_intent(watch, self.datastore)
from changedetectionio.llm.evaluator import llm_enabled_for_watch, resolve_intent
# AI off for this watch (or for its group) means no intent is handed to the
# LLM restock plugin, so it doesn't spend tokens here either — #4204.
_llm_on, _ = llm_enabled_for_watch(watch, self.datastore)
_llm_intent, _ = resolve_intent(watch, self.datastore) if _llm_on else ('', '')
plugin_availability = get_itemprop_availability_from_plugin(self.fetcher.content, fetcher_name, self.fetcher, watch.link, llm_intent=_llm_intent or None)
if plugin_availability:
+4 -2
View File
@@ -78,7 +78,9 @@ $(document).ready(function () {
// bootstrap it, this will trigger everything else
$('#browsersteps-img').bind('load', function () {
$('body').addClass('full-width');
console.log("Loaded background...");
console.log(`Loaded background ${this.naturalWidth}px` );
// For the UI width of the whole edit area
document.documentElement.style.setProperty('--browser-steps-max-width', `${this.naturalWidth+200}px` );
document.getElementById("browsersteps-selector-canvas");
c = document.getElementById("browsersteps-selector-canvas");
@@ -284,7 +286,7 @@ $(document).ready(function () {
$('#browser-steps-ui .loader .spinner').show();
// Request a new session
$.ajax({
type: "GET",
type: "POST",
url: browser_steps_start_url,
statusCode: {
400: function () {
+19
View File
@@ -179,6 +179,25 @@ function toggleOpacity(checkboxSelector, fieldSelector, inverted) {
checkbox.addEventListener('change', updateOpacity);
}
// Radio-group counterpart of toggleOpacity: fields are full opacity only while the named
// radio group sits on activeValue, otherwise greyed out. Used by the tag AI/LLM tab, where a
// ternary (On / Off / Leave it to each watch) decides whether the prompts below apply.
function toggleOpacityByRadioValue(radioName, activeValue, fieldSelector) {
const radios = document.querySelectorAll(`input[type="radio"][name="${radioName}"]`);
const fields = document.querySelectorAll(fieldSelector);
function updateOpacity() {
const active = Array.from(radios).some(radio => radio.checked && radio.value === activeValue);
fields.forEach(field => {
field.style.opacity = active ? 1 : 0.6;
});
}
// Initial setup
updateOpacity();
radios.forEach(radio => radio.addEventListener('change', updateOpacity));
}
function toggleVisibility(checkboxSelector, fieldSelector, inverted) {
const checkbox = document.querySelector(checkboxSelector);
const fields = document.querySelectorAll(fieldSelector);
+1 -1
View File
@@ -73,7 +73,7 @@ $(function () {
// Request start, needs CSRF?
$.ajax({
type: "GET",
type: "POST",
url: recheck_proxy_start_url,
}).done(function (data) {
$.each(data, function (proxy_key, state) {
+6 -4
View File
@@ -1,13 +1,15 @@
// Left-rail expand/collapse state.
// Adds `action-side-bar-expanded` to <body> whenever the rail is showing its
// labels. In pinned mode (body.actionside-bar-on) the class is already present
// from page load; in collapsed mode (body.actionsidebar-minimal) the rail only
// expands on hover/focus, so we toggle the class to match.
// labels. In 'pinned-expanded' mode (body.actionside-bar-on) the class is already
// present from page load; in 'expandable' mode (body.actionsidebar-minimal) the rail
// only expands on hover/focus, so we toggle the class to match. 'minimal' mode is
// also the collapsed rail but carries `actionsidebar-no-expand` and never rolls out.
(function() {
'use strict';
document.addEventListener('DOMContentLoaded', function() {
if (!document.body.classList.contains('actionsidebar-minimal')) {
if (!document.body.classList.contains('actionsidebar-minimal') ||
document.body.classList.contains('actionsidebar-no-expand')) {
return;
}
@@ -115,7 +115,9 @@ window.initVisualSelector = function (opts) {
$('#selector-current-xpath, #clear-selector').hide();
})
.on('load', () => {
console.log("Loaded background...");
console.log(`Loaded background ${$selectorBackgroundElem[0].naturalWidth}px`);
// For the UI width of the whole edit area
document.documentElement.style.setProperty('--visualselector-max-width', `${$selectorBackgroundElem[0].naturalWidth}px` );
c = $selectorCanvasElem[0];
xctx = c.getContext("2d");
ctx = c.getContext("2d");
@@ -32,21 +32,27 @@ $action-sidebar-content-slot: 1100px;
// Hidden on mobile: the mobile drawer (hamburger) carries these items
display: none;
}
}
body.actionside-bar-on {
.action-sidebar {
/* width: $action-sidebar-width-expanded; // reserve enough to host the expanded inner block*/
// MINIMAL MODE: the rail's footprint in the flex row is frozen at the
// collapsed width (+ the inner block's horizontal padding, which is
// content-box). The inner block is taken out of flow (absolute, below) so
// its hover expansion rolls out ON TOP of the page content instead of
// widening this flex item and reflowing `.app-main`.
body.actionsidebar-minimal & {
flex: 0 0 auto;
width: calc(#{$action-sidebar-width-collapsed} + #{$common-gap * 2});
overflow: visible; // let the expanded inner block escape the frame
}
}
// The actual interactive item block — wraps only the items, not the whole height.
//
// Two body-level modes drive the rail's display state:
// body.actionside-bar-on → always expanded (icons + labels visible all the time)
// body.actionsidebar-minimal → icon-only collapsed rail; expands on hover/focus
// `actionside-bar-on` is the default applied in templates/base.html. Both the
// hover-to-expand width animation and the label fade-in are gated on the
// Body-level classes drive the rail's display state (emitted by
// flask_app.get_sidebar_mode_class() from the `sidebar_mode` setting):
// body.actionside-bar-on → always expanded (icons + labels visible all the time)
// body.actionsidebar-minimal → icon-only collapsed rail; expands on hover/focus
// body.actionsidebar-no-expand → alongside the above: stays collapsed, no hover-out
// Both the hover-to-expand width animation and the label fade-in are gated on the
// minimal class so that the always-expanded mode (or no mode) doesn't trigger
// a needless layout jump when the user mouses across the rail.
.action-sidebar-inner {
@@ -64,12 +70,40 @@ body.actionside-bar-on {
padding-left: $common-gap;
padding-right: $common-gap;
// Hover-to-expand only fires in minimal mode.
body.actionsidebar-minimal &:hover,
body.actionsidebar-minimal &:focus-within {
// MINIMAL MODE: lift the block out of the flex flow so growing it can't
// resize the rail (and therefore can't shift the content column). The rail
// is `position: sticky`, i.e. a positioned element, so top/left/bottom here
// anchor to it; top+bottom keep the column full-height, which the footer
// list's `margin-top: auto` still needs.
//
// Once it floats above the page it needs its own backdrop, or the content
// it rolls over would read straight through the rail's translucent 5% white.
// It can't just be a flat colour: the page background is a fixed, viewport-
// sized gradient (`body:after`), so the rail repaints that same gradient with
// `background-attachment: fixed` — which makes the viewport the positioning
// area — and lays the rail's own 5% white on top. Result: opaque, but
// pixel-identical to the translucent rail it replaces, at rest and rolled out.
body.actionsidebar-minimal & {
position: absolute;
top: 0;
left: 0;
bottom: 0;
z-index: 1;
background-color: var(--color-background-page); // fallback under the gradient
background-image:
linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05)),
var(--page-background-gradient);
background-attachment: fixed, fixed;
}
// Hover-to-expand only fires in minimal mode, and not for 'minimal' (no-expand).
body.actionsidebar-minimal:not(.actionsidebar-no-expand) &:hover,
body.actionsidebar-minimal:not(.actionsidebar-no-expand) &:focus-within {
width: $action-sidebar-width-expanded;
// Enter is smoother.
transition: width 0.22s cubic-bezier(0.2, 0.7, 0.2, 1);
// Lift it off the page while it's rolled out over the content.
box-shadow: var(--color-sidebar-shadow);
}
// Always-expanded mode: no hover gate; the rail just sits open.
@@ -250,8 +284,8 @@ ul.action-sidebar-list {
// When the inner block expands in MINIMAL mode, reveal every label/badge
// together with a smooth enter. Always-expanded mode handles labels via the
// `body.actionside-bar-on` block below — no hover gate needed there.
body.actionsidebar-minimal .action-sidebar-inner:hover,
body.actionsidebar-minimal .action-sidebar-inner:focus-within {
body.actionsidebar-minimal:not(.actionsidebar-no-expand) .action-sidebar-inner:hover,
body.actionsidebar-minimal:not(.actionsidebar-no-expand) .action-sidebar-inner:focus-within {
.action-sidebar-item {
.action-label {
opacity: 1;
@@ -79,7 +79,6 @@
width: 100%;
overflow-y: scroll;
position: relative;
height: 80vh;
> img {
position: absolute;
@@ -91,3 +91,26 @@
&:hover { color: #d68a00; border-color: #d68a00; }
}
}
// State-mutating controls have to POST, and only a <button> can submit - these strip
// the browser's button chrome so they render exactly like the <a> they replaced.
.bare-btn {
background: none;
border: 0;
padding: 0;
margin: 0;
font: inherit;
// Matches `a { color: var(--color-link) }` - these replace anchors, and the row
// icons stroke with currentColor, so `inherit` would pick up .watch-controls red.
color: var(--color-link);
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--color-link);
outline-offset: 2px;
}
&--link {
text-decoration: underline;
}
}
@@ -122,7 +122,7 @@
li {
border-bottom: 1px solid var(--color-border-table-cell);
>* {
>*, >form>button {
display: block;
padding: 1rem 1.5rem;
color: var(--color-text);
@@ -134,6 +134,14 @@
background: var(--color-background-menu-link-hover);
}
}
// Buttons shrink-wrap and centre their label; anchors don't. No global
// border-box reset here, so this must not be folded into the rule above.
>form>button {
box-sizing: border-box;
width: 100%;
text-align: left;
}
&#menu-pause, &#menu-mute {
display: none;
}
@@ -18,10 +18,20 @@
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0;
// The csrf mini-form around each option is layout-transparent, so the buttons
// stay the flex items.
> form {
display: contents;
}
}
.language-option {
display: flex;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
align-items: center;
gap: 1rem;
padding: 0.25rem;
@@ -15,6 +15,13 @@
.pure-menu-item {
height: initial;
// Mini POST forms (pause/mute/log out need a csrf_token) are layout-transparent,
// so the button inside sits where the plain <a> used to.
> form {
display: contents;
}
svg {
height: 1.2rem;
}
@@ -128,6 +128,8 @@ ul#top-right-menu {
font-weight: 600;
white-space: nowrap;
text-decoration: none;
font-family: inherit;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.18);
@@ -27,6 +27,12 @@
--color-background-gradient-first: #5ad8f7;
--color-background-gradient-second: #2f50af;
--color-background-gradient-third: #9150bf;
// The page's atmosphere gradient, shared so anything that needs to sit ON the
// page background (e.g. the left rail rolling out over content) can repaint
// an identical copy instead of approximating it with a flat colour. The
// referenced gradient stops are re-declared per theme below, so this single
// definition follows light/dark automatically.
--page-background-gradient: linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%);
--color-background: var(--color-white);
--color-text: var(--color-grey-200);
--color-link: #1b98f8;
@@ -1,3 +1,12 @@
body:has(#browser-steps:target) .edit-form {
max-width: min(var(--browser-steps-max-width, 1280px), 95vw);
width: 95%;
}
body:has(#visualselector:target) .edit-form {
width: 95%;
max-width: min(var(--visualselector-max-width, 1280px), 95vw);
}
#selector-wrapper {
height: 100%;
@@ -205,7 +205,7 @@ body.watch-selection-active #checkbox-operations {
}
&.queued {
a.recheck {
.recheck {
display: none !important;
}
@@ -216,7 +216,7 @@ body.watch-selection-active #checkbox-operations {
}
&.paused {
a.pause-toggle {
.pause-toggle {
&.state-on {
display: inline !important;
}
@@ -228,7 +228,7 @@ body.watch-selection-active #checkbox-operations {
}
&.notification_muted {
a.mute-toggle {
.mute-toggle {
&.state-on {
display: inline !important;
}
@@ -42,7 +42,8 @@
@use "parts/sub_tabs";
// Smooth transitions for theme switching
body,
// Disabled - people complained
/*body,
.pure-table,
.pure-table thead,
.pure-table td,
@@ -65,7 +66,7 @@ code,
a,
.watch-controls img {
transition: color 0.4s ease, background-color 0.4s ease, background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
}
}*/
body {
color: var(--color-text);
@@ -305,7 +306,7 @@ code {
body:after {
content: "";
background: linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%);
background: var(--page-background-gradient);
}
body:after,
@@ -697,12 +698,12 @@ footer {
}
}
@media only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: $desktop-wide-breakpoint) {
.edit-form {
padding: 0.5em;
margin: 0;
width: 100%;
}
#nav-menu {
@@ -1152,4 +1153,8 @@ header {
cursor: pointer;
width: 1.4rem; /*it's slightly more wider than square so default auto will trim it slightly */
}
}
textarea::placeholder {
white-space: pre-wrap;
}
File diff suppressed because one or more lines are too long
+89 -26
View File
@@ -5,7 +5,8 @@ from changedetectionio.strtobool import strtobool
from changedetectionio.validate_url import is_safe_valid_url
from flask import (
flash
flash,
has_request_context
)
from flask_babel import gettext
@@ -48,6 +49,9 @@ dictfilt = lambda x, y: dict([(i, x[i]) for i in x if i in set(y)])
# Is there an existing library to ensure some data store (JSON etc) is in sync with CRUD methods?
# Open a github issue if you know something :)
# https://stackoverflow.com/questions/6190468/how-to-trigger-function-on-value-change
_TAG_UUID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
__version_check = True
@@ -661,9 +665,8 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
# NOTE: dict() is shallow copy but safe since add_watch() deepcopies it
with self.lock:
extras = dict(self.data['watching'][uuid])
new_uuid = self.add_watch(url=url, extras=extras)
watch = self.data['watching'][new_uuid]
return new_uuid
# None when add_watch() refused it (e.g. PAGE_WATCH_LIMIT), having already flashed why
return self.add_watch(url=url, extras=extras)
def url_exists(self, url):
@@ -679,6 +682,38 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
self.__data['watching'][uuid].clear_watch()
self.__data['watching'][uuid].commit()
@property
def watch_limit(self):
"""Total watches allowed by PAGE_WATCH_LIMIT, or None when there is no limit.
None means "unlimited" and is the normal case - the env var being absent, empty or
unparseable all leave the limit switched off entirely. There is no default.
"""
limit = os.getenv('PAGE_WATCH_LIMIT')
if not limit:
return None
try:
return int(limit)
except ValueError:
logger.warning(f"Invalid PAGE_WATCH_LIMIT value: {limit}, ignoring limit check")
return None
def watch_limit_reached(self):
"""True when the limit is set and leaves no room for another watch.
add_watch() enforces this on its own, but it can only return None. Callers that can
report something better - a 429 in the API, one flash instead of one per row in the
importers - should check this first.
"""
limit = self.watch_limit
return limit is not None and len(self.__data['watching']) >= limit
def watch_limit_message(self):
"""The single wording for a blocked add, so every UI surface says the same thing."""
return gettext("Watch limit reached ({current}/{limit} watches). Cannot add more watches.").format(
current=len(self.__data['watching']), limit=self.watch_limit
)
def add_watch(self, url, tag='', extras=None, tag_uuids=None, save_immediately=True, seed_data_dir=None):
"""
seed_data_dir: optional path to an existing directory (already in the watch's
@@ -742,32 +777,48 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
return False
if not is_safe_valid_url(url):
from flask import has_request_context
if has_request_context():
flash(gettext('Watch protocol is not permitted or invalid URL format'), 'error')
else:
logger.error(f"add_watch: URL '{url}' is not permitted or invalid, skipping.")
return None
# Check PAGE_WATCH_LIMIT if set
page_watch_limit = os.getenv('PAGE_WATCH_LIMIT')
if page_watch_limit:
try:
page_watch_limit = int(page_watch_limit)
current_watch_count = len(self.__data['watching'])
if current_watch_count >= page_watch_limit:
logger.error(f"Watch limit reached: {current_watch_count}/{page_watch_limit} watches. Cannot add {url}")
flash(gettext("Watch limit reached ({current}/{limit} watches). Cannot add more watches.").format(current=current_watch_count, limit=page_watch_limit), 'error')
return None
except ValueError:
logger.warning(f"Invalid PAGE_WATCH_LIMIT value: {page_watch_limit}, ignoring limit check")
# Backstop for PAGE_WATCH_LIMIT - every add path funnels through here, so nothing can
# get past the limit even if a caller forgets to pre-check watch_limit_reached().
if self.watch_limit_reached():
logger.error(f"Watch limit reached: {len(self.__data['watching'])}/{self.watch_limit} watches. Cannot add {url}")
# The CLI (-u) and the API's background import thread have no request context,
# where flash() raises instead of reporting anything
if has_request_context():
flash(self.watch_limit_message(), 'error')
return None
if tag and type(tag) == str:
# Then it's probably a string of the actual tag by name, split and add it
for t in tag.split(','):
# for each stripped tag, add tag as UUID
for a_t in t.split(','):
tag_uuid = self.add_tag(a_t)
# A comma separated string of tag *titles*, created when they don't exist yet.
# An existing tag's UUID is accepted here too: the API documented this field as taking
# a UUID for years, and honouring that beats creating a tag *titled* with the UUID.
existing_tag_uuids = self.__data['settings']['application'].get('tags', {})
for tag_name in tag.split(','):
tag_name = tag_name.strip()
if not tag_name:
continue
if _TAG_UUID_RE.match(tag_name):
if tag_name in existing_tag_uuids:
apply_extras['tags'].append(tag_name)
continue
# UUID-shaped but no such tag, and no tag literally titled that either -
# a stale or foreign ID. Skip it rather than leave behind a group named
# after a UUID, which is never what the caller wanted.
if not self.tag_uuid_for_title(tag_name):
logger.warning(f"Tag '{tag_name}' looks like a UUID but no such tag exists, skipping")
continue
tag_uuid = self.add_tag(tag_name)
# add_tag() returns False for a title it won't create - never let that into the list,
# a falsy entry blows up every lookup of watch['tags']
if tag_uuid:
apply_extras['tags'].append(tag_uuid)
# Or if UUIDs given directly
@@ -1041,6 +1092,18 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
return ret
def tag_uuid_for_title(self, title):
"""UUID of the tag with this title (case/space insensitive), or None. Creates nothing."""
n = title.strip().lower()
if not n:
return None
for uuid, tag in self.__data['settings']['application'].get('tags', {}).items():
if n == tag.get('title', '').lower().strip():
return uuid
return None
def add_tag(self, title):
# If name exists, return that
n = title.strip().lower()
@@ -1048,10 +1111,10 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
if not n:
return False
for uuid, tag in self.__data['settings']['application'].get('tags', {}).items():
if n == tag.get('title', '').lower().strip():
logger.warning(f"Tag '{title}' already exists, skipping creation.")
return uuid
existing_uuid = self.tag_uuid_for_title(title)
if existing_uuid:
logger.warning(f"Tag '{title}' already exists, skipping creation.")
return existing_uuid
# Eventually almost everything todo with a watch will apply as a Tag
# So we use the same model as a Watch
+122 -68
View File
@@ -7,7 +7,6 @@ This module provides the FileSavingDataStore abstract class that implements:
- Atomic file writes safe for NFS/NAS
"""
import glob
import json
import os
import tempfile
@@ -208,7 +207,46 @@ def save_watch_atomic(watch_dir, uuid, watch_dict):
def load_watch_from_file(watch_json, uuid, rehydrate_entity_func):
class _EntityFileMissing:
"""Sentinel: the directory exists but holds no file of this entity type."""
__slots__ = ()
def __repr__(self):
return '<ENTITY_FILE_MISSING>'
def __bool__(self):
return False
ENTITY_FILE_MISSING = _EntityFileMissing()
def iter_entity_dirs(datastore_path):
"""
Yield (uuid, dir_path) for every immediate subdirectory of the datastore.
Replaces glob.glob(f"{datastore_path}/*/watch.json"). glob has to build a
regex from the "*" component and fnmatch every name in the directory, then
stat each candidate to test the literal "watch.json" part. With ~60k watches
that measured as 238k allocations in fnmatch.filter plus one stat per
directory, all to produce a list we then split back into uuid + path.
scandir hands us the names directly, and entry.is_dir() is free on Linux
because it reads d_type straight from the dirent (no stat syscall).
Symlinked directories are followed, matching glob's behaviour.
"""
try:
with os.scandir(datastore_path) as it:
for entry in it:
try:
if entry.is_dir():
yield entry.name, entry.path
except OSError:
# Raced with a delete, or a broken symlink - not loadable either way
continue
except FileNotFoundError:
return
def load_watch_from_file(watch_json, uuid, rehydrate_entity_func, missing_ok=False):
"""
Load a watch from its JSON file.
@@ -216,29 +254,39 @@ def load_watch_from_file(watch_json, uuid, rehydrate_entity_func):
watch_json: Path to the watch.json file
uuid: Watch UUID
rehydrate_entity_func: Function to convert dict to Watch object
missing_ok: When True, a missing file returns ENTITY_FILE_MISSING instead of
logging an error. Used by load_all_watches, which walks every
datastore subdirectory - some of those are tag dirs, not watches.
Returns:
Watch object or None if failed
Watch object, ENTITY_FILE_MISSING if absent and missing_ok, else None
"""
try:
# Check file size before reading
file_size = os.path.getsize(watch_json)
MAX_WATCH_SIZE = 10 * 1024 * 1024 # 10MB
if file_size > MAX_WATCH_SIZE:
logger.critical(
f"CORRUPTED WATCH DATA: Watch {uuid} file is unexpectedly large: "
f"{file_size / 1024 / 1024:.2f}MB (max: {MAX_WATCH_SIZE / 1024 / 1024}MB). "
f"File: {watch_json}. This indicates a bug or data corruption. "
f"Watch will be skipped."
)
# Open first, then take the size from the already-open fd. Doing it this way
# costs one syscall instead of stat()+open(), which matters when this is
# called once per directory across a large datastore.
try:
f = open(watch_json, 'rb')
except (FileNotFoundError, NotADirectoryError):
if missing_ok:
return ENTITY_FILE_MISSING
logger.error(f"Watch file not found: {watch_json} for watch {uuid}")
return None
if HAS_ORJSON:
with open(watch_json, 'rb') as f:
watch_data = orjson.loads(f.read())
else:
with open(watch_json, 'r', encoding='utf-8') as f:
watch_data = json.load(f)
with f:
file_size = os.fstat(f.fileno()).st_size
MAX_WATCH_SIZE = 10 * 1024 * 1024 # 10MB
if file_size > MAX_WATCH_SIZE:
logger.critical(
f"CORRUPTED WATCH DATA: Watch {uuid} file is unexpectedly large: "
f"{file_size / 1024 / 1024:.2f}MB (max: {MAX_WATCH_SIZE / 1024 / 1024}MB). "
f"File: {watch_json}. This indicates a bug or data corruption. "
f"Watch will be skipped."
)
return None
raw = f.read()
watch_data = orjson.loads(raw) if HAS_ORJSON else json.loads(raw.decode('utf-8'))
# Rehydrate and return watch object
watch_obj = rehydrate_entity_func(uuid, watch_data)
@@ -262,9 +310,6 @@ def load_watch_from_file(watch_json, uuid, rehydrate_entity_func):
return None
# Re-raise if it's not a JSON parsing error
raise
except FileNotFoundError:
logger.error(f"Watch file not found: {watch_json} for watch {uuid}")
return None
except Exception as e:
logger.error(f"Failed to load watch {uuid} from {watch_json}: {e}")
return None
@@ -293,22 +338,27 @@ def load_all_watches(datastore_path, rehydrate_entity_func):
if not os.path.exists(datastore_path):
return watching
# Find all watch.json files using glob (faster than manual directory traversal)
glob_start = time.perf_counter()
watch_files = glob.glob(os.path.join(datastore_path, "*", "watch.json"))
glob_time = time.perf_counter() - glob_start
# One scandir over the datastore, then open each {uuid}/watch.json directly.
# `total` counts candidate directories, so it includes tag dirs that hold no
# watch.json - it is an upper bound used only for progress logging.
scan_start = time.perf_counter()
entity_dirs = list(iter_entity_dirs(datastore_path))
scan_time = time.perf_counter() - scan_start
total = len(watch_files)
logger.debug(f"Found {total} watch.json files in {glob_time:.3f}s")
total = len(entity_dirs)
logger.debug(f"Scanned {total} datastore directories in {scan_time:.3f}s")
loaded = 0
failed = 0
skipped = 0
for watch_json in watch_files:
# Extract UUID from path: /datastore/{uuid}/watch.json
uuid_dir = os.path.basename(os.path.dirname(watch_json))
watch = load_watch_from_file(watch_json, uuid_dir, rehydrate_entity_func)
if watch:
for uuid_dir, dir_path in entity_dirs:
watch_json = os.path.join(dir_path, "watch.json")
watch = load_watch_from_file(watch_json, uuid_dir, rehydrate_entity_func, missing_ok=True)
if watch is ENTITY_FILE_MISSING:
# Not a watch directory (e.g. holds tag.json instead) - not an error
skipped += 1
elif watch:
watching[uuid_dir] = watch
loaded += 1
@@ -318,6 +368,9 @@ def load_all_watches(datastore_path, rehydrate_entity_func):
# load_watch_from_file already logged the specific error
failed += 1
if skipped:
logger.debug(f"Skipped {skipped} directories with no watch.json")
elapsed = time.perf_counter() - start_time
load_rate = loaded / elapsed if elapsed > 0 else 0
@@ -333,7 +386,7 @@ def load_all_watches(datastore_path, rehydrate_entity_func):
return watching
def load_tag_from_file(tag_json, uuid, rehydrate_entity_func):
def load_tag_from_file(tag_json, uuid, rehydrate_entity_func, missing_ok=False):
"""
Load a tag from its JSON file.
@@ -341,29 +394,37 @@ def load_tag_from_file(tag_json, uuid, rehydrate_entity_func):
tag_json: Path to the tag.json file
uuid: Tag UUID
rehydrate_entity_func: Function to convert dict to Tag object
missing_ok: When True, a missing file returns ENTITY_FILE_MISSING instead of
logging. Used by load_all_tags, which walks every datastore
subdirectory - most of those are watch dirs, not tags.
Returns:
Tag object or None if failed
Tag object, ENTITY_FILE_MISSING if absent and missing_ok, else None
"""
try:
# Check file size before reading
file_size = os.path.getsize(tag_json)
MAX_TAG_SIZE = 1 * 1024 * 1024 # 1MB
if file_size > MAX_TAG_SIZE:
logger.critical(
f"CORRUPTED TAG DATA: Tag {uuid} file is unexpectedly large: "
f"{file_size / 1024 / 1024:.2f}MB (max: {MAX_TAG_SIZE / 1024 / 1024}MB). "
f"File: {tag_json}. This indicates a bug or data corruption. "
f"Tag will be skipped."
)
# See load_watch_from_file: open first, size from the open fd, one syscall.
try:
f = open(tag_json, 'rb')
except (FileNotFoundError, NotADirectoryError):
if missing_ok:
return ENTITY_FILE_MISSING
logger.debug(f"Tag file not found: {tag_json} for tag {uuid}")
return None
if HAS_ORJSON:
with open(tag_json, 'rb') as f:
tag_data = orjson.loads(f.read())
else:
with open(tag_json, 'r', encoding='utf-8') as f:
tag_data = json.load(f)
with f:
file_size = os.fstat(f.fileno()).st_size
MAX_TAG_SIZE = 1 * 1024 * 1024 # 1MB
if file_size > MAX_TAG_SIZE:
logger.critical(
f"CORRUPTED TAG DATA: Tag {uuid} file is unexpectedly large: "
f"{file_size / 1024 / 1024:.2f}MB (max: {MAX_TAG_SIZE / 1024 / 1024}MB). "
f"File: {tag_json}. This indicates a bug or data corruption. "
f"Tag will be skipped."
)
return None
raw = f.read()
tag_data = orjson.loads(raw) if HAS_ORJSON else json.loads(raw.decode('utf-8'))
tag_data['processor'] = 'restock_diff'
# Rehydrate tag (convert dict to Tag object)
@@ -389,9 +450,6 @@ def load_tag_from_file(tag_json, uuid, rehydrate_entity_func):
return None
# Re-raise if it's not a JSON parsing error
raise
except FileNotFoundError:
logger.debug(f"Tag file not found: {tag_json} for tag {uuid}")
return None
except Exception as e:
logger.error(f"Failed to load tag {uuid} from {tag_json}: {e}")
return None
@@ -417,23 +475,15 @@ def load_all_tags(datastore_path, rehydrate_entity_func):
if not os.path.exists(datastore_path):
return tags
# Find all tag.json files using glob
tag_files = glob.glob(os.path.join(datastore_path, "*", "tag.json"))
total = len(tag_files)
if total == 0:
logger.debug("No tag.json files found")
return tags
logger.debug(f"Found {total} tag.json files")
# One scandir over the datastore; most subdirectories are watches, not tags.
loaded = 0
failed = 0
for tag_json in tag_files:
# Extract UUID from path: /datastore/{uuid}/tag.json
uuid_dir = os.path.basename(os.path.dirname(tag_json))
tag = load_tag_from_file(tag_json, uuid_dir, rehydrate_entity_func)
for uuid_dir, dir_path in iter_entity_dirs(datastore_path):
tag_json = os.path.join(dir_path, "tag.json")
tag = load_tag_from_file(tag_json, uuid_dir, rehydrate_entity_func, missing_ok=True)
if tag is ENTITY_FILE_MISSING:
continue
if tag:
tags[uuid_dir] = tag
loaded += 1
@@ -441,6 +491,10 @@ def load_all_tags(datastore_path, rehydrate_entity_func):
# load_tag_from_file already logged the specific error
failed += 1
if loaded == 0 and failed == 0:
logger.debug("No tag.json files found")
return tags
if failed > 0:
logger.warning(f"Loaded {loaded} tags, {failed} tags FAILED to load")
else:
+10 -4
View File
@@ -271,13 +271,19 @@
<div class="modal-body">
<div class="language-list">
{% for locale, lang_data in available_languages.items()|sort %}
<a href="{{ url_for('set_language', locale=locale, redirect=request.path) }}" class="language-option" data-locale="{{ locale }}">
<span class="lang-option {{ lang_data.flag }}"></span> <span class="language-name">{{ lang_data.name }}</span>
</a>
<form method="POST" action="{{ url_for('set_language', locale=locale, redirect=request.path) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="language-option" data-locale="{{ locale }}">
<span class="lang-option {{ lang_data.flag }}"></span> <span class="language-name">{{ lang_data.name }}</span>
</button>
</form>
{% endfor %}
</div>
<div>
<a href="{{ url_for('ui.delete_locale_language_session_var_if_it_exists', redirect=request.path) }}" >{{ _('Auto-detect from browser') }}</a>
<form method="POST" action="{{ url_for('ui.delete_locale_language_session_var_if_it_exists', redirect=request.path) }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bare-btn">{{ _('Auto-detect from browser') }}</button>
</form>
</div>
<div>
{{ _('Language support is in beta, please help us improve by opening a PR on GitHub with any updates.') }}
@@ -5,59 +5,130 @@
llm_configured — bool: LLM provider is configured in settings
form — the WTForms form (must have .llm_intent and .llm_change_summary fields)
Optional (watch edit only):
Group/tag edit only:
llm_group_edit — bool: True when rendering the tag/group edit page. This is the ONLY
way to tell the two contexts apart — the tags blueprint also passes
the tag dict as `watch`, so `watch` is truthy in both.
form.llm_backend_profile is ternary here (On / Off / Leave it to each
watch) and is the group's only AI control; on a watch it is a checkbox.
Watch edit only:
watch — the Watch object (for processor check and prefilter display)
llm_group_overrides — dict returned by _resolve_llm_group_overrides():
{'llm_intent': {'value': str, 'group_name': str} | None,
'llm_change_summary': {'value': str, 'group_name': str} | None}
Present only in watch edit context; absent in tag edit context.
'llm_change_summary': {'value': str, 'group_name': str} | None,
'llm_backend_profile': {'value': bool, 'group_name': str} | None}
The two prompt entries are non-None only when the watch has no own
value AND a linked group set to "On" has one — that is what puts
"From group '<name>': <value>" in the placeholder.
llm_backend_profile is non-None when a linked group has taken the
on/off decision (On or Off, not "leave it to each watch") — #4204.
Usage in watch edit (edit.html):
{% include "edit/include_llm_intent.html" %}
Usage in tag edit (edit-tag.html):
{% include "edit/include_llm_intent.html" %}
(watch is not set → tag mode: no processor check, no examples, different description)
Usage (both): {% include "edit/include_llm_intent.html" %}
#}
{% from '_helpers.html' import render_field %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_ternary_field %}
{# Processor check only applies in watch-edit context (llm_group_overrides present). #}
{# In tag/group edit context the AI section is always visible. #}
{% set llm_group_mode = llm_group_edit|default(false) %}
{# Processor check only applies in watch-edit context. #}
{# In tag/group edit context the AI section is always visible. #}
{# Processors whose edit form carries the AI Intent / Change Summary fields (i.e. those whose
form inherits processor_text_json_diff_form). text_json_diff + restock_diff today. #}
{% if llm_group_overrides is defined %}
{% set show_ai_section = not watch.get('processor') or watch.get('processor') in ['text_json_diff', 'restock_diff'] %}
{% else %}
{% if llm_group_mode %}
{% set show_ai_section = true %}
{% else %}
{% set show_ai_section = not watch.get('processor') or watch.get('processor') in ['text_json_diff', 'restock_diff'] %}
{% endif %}
{# An unrendered switch is absent from the POST, and WTForms reads that as off. So whenever we
do NOT render the AI switch (no LLM provider configured, or a processor whose form has no AI
fields) we carry its saved state through in a hidden input — otherwise merely saving the page
would silently switch AI off. #}
{% if not (show_ai_section and llm_configured) %}
{% if llm_group_mode %}
{# Group: ternary, so preserve whichever of the three states it is in #}
{% if form.llm_backend_profile.data is not none %}<input type="hidden" name="llm_backend_profile" value="{{ 'true' if form.llm_backend_profile.data else 'false' }}">{% endif %}
{% elif form.llm_backend_profile.data %}
<input type="hidden" name="llm_backend_profile" value="y">
{% endif %}
{% endif %}
{% if show_ai_section %}
{# ── Configured: show the intent + summary fields ────────────────── #}
{% if llm_configured %}
<div class="border-fieldset" id="llm-intent-section">
<h3>&#x2728; {{ _('AI') }}</h3>
<div id="llm-intent-section">
{# — AI Change Intent — #}
<h4 style="margin: 0 0 0.3em 0;">{{ _('AI — Notify when…') }}</h4>
{% if llm_group_mode %}
{# The group's single AI control. "On" is also what makes the prompts below cascade to the
watches (tag_llm_applies_to_watches), so there is no second "does this override?" flag. #}
<div class="pure-control-group inline-radio" id="llm-ai-enabled-row">
{{ render_ternary_field(form.llm_backend_profile) }}
<span class="pure-form-message-inline">
{{ _('<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. <strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this group has no say; each watch uses its own AI settings.')|safe }}
</span>
</div>
{# The prompts below only mean something in the "On" state, so grey them out otherwise — the
radio counterpart of the toggleOpacity cue used by #overrides_watch on the restock tab. #}
<script id="llm-group-opacity-toggle">
$(document).ready(function () {
toggleOpacityByRadioValue('llm_backend_profile', 'true', '#change-intent-notify-me-when, #change-summary');
});
</script>
{% else %}
{# Per-watch AI on/off (#4204). Resolution is global settings → group → watch, so a group that
has taken the decision (its AI setting is On or Off rather than "leave it to each watch")
owns this control: we disable it, show the state the group decided, and name the group.
The watch's own preference is not lost — because the field isn't user-writable in this
state, edit.py ignores whatever the POST says for it and keeps the stored value. #}
{% set profile_group = llm_group_overrides.llm_backend_profile if llm_group_overrides is defined else none %}
<div class="pure-control-group" id="llm-ai-enabled-row">
{# Only the checkbox is dimmed — the explanation of *why* has to stay readable. #}
<div{% if profile_group %} style="opacity: 0.6;"{% endif %}>
{% if profile_group %}
{# Show what the group decided, not this watch's now-inert own value. #}
{% set dummy = form.llm_backend_profile.__setattr__('checked', profile_group.value) %}
{{ render_checkbox_field(form.llm_backend_profile, disabled=True) }}
{% else %}
{{ render_checkbox_field(form.llm_backend_profile) }}
{% endif %}
</div>
{% if profile_group %}
{# The group name links to its edit page so the decision can be changed where it lives.
Built as escaped markup and passed into the sentence, so translators keep one string. #}
{%- set group_link -%}
<a href="{{ url_for('tags.form_tag_edit', uuid=profile_group.group_uuid) }}#ai-llm">{{ profile_group.group_name }}</a>
{%- endset -%}
<span class="pure-form-message-inline">
{% if profile_group.value %}
{{ _("Group %(name)s decides this: AI is ON for every watch in that group.", name=group_link) | safe }}
{% else %}
{{ _("Group %(name)s decides this: AI is OFF for every watch in that group.", name=group_link) | safe }}
{% endif %}
</span>
{% endif %}
</div>
{% endif %}
<div class="border-fieldset" id="change-intent-notify-me-when">
<p class="pure-form-message-inline" style="margin-top:0">
{% if watch is defined and watch %}
{% if not llm_group_mode %}
{{ _('Describe what you care about. The AI evaluates every detected change against this and only notifies you when it matches.') }}
{% else %}
{{ _('Set a change intent for all watches in this tag/group. Each watch can override with its own.') }}
{% endif %}
</p>
<div class="pure-control-group">
{% if watch is defined and watch and llm_group_overrides is defined and llm_group_overrides.llm_intent %}
{% if not llm_group_mode and llm_group_overrides is defined and llm_group_overrides.llm_intent %}
{% set intent_placeholder = _("From group '%(name)s': %(value)s", name=llm_group_overrides.llm_intent.group_name, value=llm_group_overrides.llm_intent.value) %}
{% elif watch is defined and watch %}
{% elif not llm_group_mode %}
{% set intent_placeholder = _('e.g. Alert me when the price drops below $300, or a new product is launched. Ignore footer and navigation changes.') %}
{% else %}
{% set intent_placeholder = _('e.g. Flag price changes or new product launches across all watches in this group') %}
{% endif %}
{{ render_field(form.llm_intent, placeholder=intent_placeholder, rows=5, class="pure-input-1") }}
</div>
{% if watch is defined and watch %}
{% if not llm_group_mode %}
<div class="pure-form-message-inline">
<strong>{{ _('Examples:') }}</strong>
<ul style="margin: 0.3em 0 0 1.2em; padding: 0;">
@@ -67,19 +138,19 @@
<li><em>{{ _('Only important if package versions change or a CVE is mentioned') }}</em></li>
</ul>
</div>
{% if watch.get('llm_prefilter') %}
{% if watch is defined and watch.get('llm_prefilter') %}
<div class="pure-form-message-inline" style="margin-top: 0.5em;">
<small>{{ _('AI pre-filter active: <code>%(filter)s</code> — narrows content scope before evaluation', filter=watch.get('llm_prefilter')|e) | safe }}</small>
</div>
{% endif %}
{% endif %}
<hr style="margin: 1.2em 0; border: none; border-top: 1px solid var(--color-border, #ddd);">
</div>
<div class="border-fieldset" id="change-summary">
{# — AI Change Summary — #}
<h4 style="margin: 0 0 0.3em 0;">{{ _('AI Change Summary') }}</h4>
<p class="pure-form-message-inline" style="margin-top:0">
{% if watch is defined and watch %}
{% if not llm_group_mode %}
{{ _('When a change is detected, the AI describes it according to your instructions and replaces <code>%(diff)s</code> in your notification. Use <code>%(raw_diff)s</code> if you still want the original diff.',
diff='{{diff}}', raw_diff='{{raw_diff}}') | safe }}
{% else %}
@@ -87,34 +158,30 @@
{% endif %}
</p>
<div class="pure-control-group">
{% if watch is defined and watch and llm_group_overrides is defined and llm_group_overrides.llm_change_summary %}
{% if not llm_group_mode and llm_group_overrides is defined and llm_group_overrides.llm_change_summary %}
{% set summary_placeholder = _("From group '%(name)s': %(value)s", name=llm_group_overrides.llm_change_summary.group_name, value=llm_group_overrides.llm_change_summary.value) %}
{% else %}
{% set summary_placeholder = form.llm_change_summary.render_kw['placeholder'] %}
{% endif %}
{{ render_field(form.llm_change_summary, placeholder=summary_placeholder, rows=5, class="pure-input-1") }}
</div>
<div style="margin-top: 0.3em;">
<a href="#" class="pure-button button-xsmall" onclick="var t=document.getElementById('llm_change_summary'); if(!t.value&amp;&amp;t.placeholder) t.value=t.placeholder; return false;">{{ _('Modify default prompt') }}</a>
</div>
<div class="pure-control-group" style="margin-top: 0.6em;">
<label>{{ form.llm_change_summary_mode.label.text }}</label>
<div>
{% for subfield in form.llm_change_summary_mode %}
<label class="pure-radio" style="display:block; font-weight:normal; margin-bottom:0.3em;">
{{ subfield() }} {{ subfield.label.text }}
</label>
{% endfor %}
<br>
<div class="inline-radio">
{{ render_field(form.llm_change_summary_mode) }}
<span class="pure-form-message-inline">
{% if not llm_group_mode %}
{{ _('Appending keeps the prompt inherited from the group or from global settings and adds your text after it, so later edits to that prompt still reach this watch.') }}
{% else %}
{{ _('Appending keeps the prompt inherited from global settings and adds your text after it, so later edits to that prompt still reach this group.') }}
{% endif %}
</span>
</div>
<span class="pure-form-message-inline">
{% if watch is defined and watch %}
{{ _('Appending keeps the prompt inherited from the group or from global settings and adds your text after it, so later edits to that prompt still reach this watch.') }}
{% else %}
{{ _('Appending keeps the prompt inherited from global settings and adds your text after it, so later edits to that prompt still reach this group.') }}
{% endif %}
</span>
</div>
{% if watch is defined and watch %}
{% if not llm_group_mode %}
<div class="pure-form-message-inline">
<strong>{{ _('Examples:') }}</strong>
<ul style="margin: 0.3em 0 0 1.2em; padding: 0;">
@@ -125,13 +192,14 @@
</div>
{% endif %}
</div>
</div>
{# ── Not configured: greyed-out prompt to configure ──────────────── #}
{% else %}
<div class="border-fieldset" id="llm-intent-section-disabled" style="opacity: 0.5;">
<h3>&#x2728; {{ _('AI') }}</h3>
<p>
{% if watch is defined and watch %}
{% if not llm_group_mode %}
{{ _('Configure an AI / LLM provider in <a href="%(url)s">Settings → AI / LLM</a> to enable AI Change Intent and AI Change Summary.',
url=url_for('settings.settings_page') + '#ai') | safe }}
{% else %}
+12 -3
View File
@@ -5,14 +5,23 @@
{% if current_user.is_authenticated or not has_password %}
{% if not current_diff_url %}
<li class="pure-menu-item" id="menu-pause">
<a class="status-pill {{ 'paused' if all_paused }}" href="{{ url_for('settings.toggle_all_paused') }}" aria-label="{% if all_paused %}{{ _('Resume automatic scheduling') }}{% else %}{{ _('Pause auto-queue scheduling of watches') }}{% endif %}" title="{% if all_paused %}{{ _('Scheduling paused — click to resume') }}{% else %}{{ _('Scheduling active — click to pause all') }}{% endif %}"><span class="live-dot"></span>{% if all_paused %}{{ _('Paused') }}{% else %}{{ _('Running') }}{% endif %}</a>
<form method="POST" action="{{ url_for('settings.toggle_all_paused') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="status-pill {{ 'paused' if all_paused }}" aria-label="{% if all_paused %}{{ _('Resume automatic scheduling') }}{% else %}{{ _('Pause auto-queue scheduling of watches') }}{% endif %}" title="{% if all_paused %}{{ _('Scheduling paused — click to resume') }}{% else %}{{ _('Scheduling active — click to pause all') }}{% endif %}"><span class="live-dot"></span>{% if all_paused %}{{ _('Paused') }}{% else %}{{ _('Running') }}{% endif %}</button>
</form>
</li>
<li class="pure-menu-item " id="menu-mute">
<a class="status-pill {{ 'muted' if all_muted }}" href="{{ url_for('settings.toggle_all_muted') }}" aria-label="{% if all_muted %}{{ _('Unmute notifications') }}{% else %}{{ _('Mute notifications') }}{% endif %}" title="{% if all_muted %}{{ _('Notifications are muted - click to unmute') }}{% else %}{{ _('Mute notifications') }}{% endif %}"><i data-feather="{{ 'bell-off' if all_muted else 'bell' }}" class="action-icon"></i>{% if all_muted %}{{ _('Muted') }}{% else %}{{ _('Alerts on') }}{% endif %}</a>
<form method="POST" action="{{ url_for('settings.toggle_all_muted') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="status-pill {{ 'muted' if all_muted }}" aria-label="{% if all_muted %}{{ _('Unmute notifications') }}{% else %}{{ _('Mute notifications') }}{% endif %}" title="{% if all_muted %}{{ _('Notifications are muted - click to unmute') }}{% else %}{{ _('Mute notifications') }}{% endif %}"><i data-feather="{{ 'bell-off' if all_muted else 'bell' }}" class="action-icon"></i>{% if all_muted %}{{ _('Muted') }}{% else %}{{ _('Alerts on') }}{% endif %}</button>
</form>
</li>
{%- if current_user.is_authenticated -%}
<li class="pure-menu-item menu-collapsible">
<a href="{{ url_for('logout', redirect=request.path) }}" ><i data-feather="log-out" class="action-icon"></i>&nbsp;{{ _('Log out') }}</a>
<form method="POST" action="{{ url_for('logout', redirect=request.path) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bare-btn"><i data-feather="log-out" class="action-icon"></i>&nbsp;{{ _('Log out') }}</button>
</form>
</li>
{%- endif -%}
+2 -2
View File
@@ -7,12 +7,12 @@
<li class="action-sidebar-li" id="action-sidebar-logo">
{%- if has_password and not current_user.is_authenticated -%}
<a id="cdio-logo" href="https://changedetection.io" rel="noopener">
<a id="cdio-logo" href="https://changedetection.io" rel="noopener" title="ChangeDetection.io intelligent web page change detection.">
<span id="logo-expanded"><strong>Change</strong>Detection.io</span>
<span id="logo-short"><strong>CD</strong>IO</span>
</a>
{%- else -%}
<a id="cdio-logo" href="{{url_for('watchlist.index')}}">
<a id="cdio-logo" href="{{url_for('watchlist.index')}}" title="ChangeDetection.io intelligent web page change detection.">
<span id="logo-expanded"><strong>Change</strong>Detection.io</span>
<span id="logo-short"><strong>CD</strong>IO</span>
</a>
+4 -3
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
@@ -202,7 +203,7 @@ def pytest_configure(config):
LiveServer uses nested functions that can't be pickled.
Setting 'fork' explicitly:
- Maintains compatibility with Python 3.10-3.13 (where 'fork' was already default)
- Maintains compatibility with Python 3.11-3.13 (where 'fork' was already default)
- Fixes Python 3.14 pickling errors
- Only affects Unix-like systems (Windows uses 'spawn' regardless)
+133 -12
View File
@@ -22,6 +22,19 @@ def _make_datastore(llm_cfg=None, tags=None):
return ds
def _make_tag(ai=True, **fields):
"""Build a tag dict.
`ai` is the group's single AI control (llm_backend_profile): True = on, and its AI
settings apply to its watches; False = off for every watch in the group; None = the
group has no say. Defaults to True because most cases here are about what a group set
to "On" does.
"""
tag = {'title': 'grp', 'llm_backend_profile': ai}
tag.update(fields)
return tag
def _make_watch(llm_intent='', llm_change_summary='', tags=None, uuid='test-uuid-1234'):
w = {}
w['llm_intent'] = llm_intent
@@ -43,7 +56,7 @@ class TestResolveIntent:
def test_watch_intent_takes_priority(self):
from changedetectionio.llm.evaluator import resolve_intent
tag = {'title': 'mygroup', 'llm_intent': 'group intent'}
tag = _make_tag(title='mygroup', llm_intent='group intent')
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_intent='watch intent', tags=['tag-1'])
@@ -54,7 +67,7 @@ class TestResolveIntent:
def test_tag_intent_used_when_watch_has_none(self):
from changedetectionio.llm.evaluator import resolve_intent
tag = {'title': 'pricing-group', 'llm_intent': 'flag price drops'}
tag = _make_tag(title='pricing-group', llm_intent='flag price drops')
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_intent='', tags=['tag-1'])
@@ -73,10 +86,10 @@ class TestResolveIntent:
assert source == ''
def test_tag_applied_to_all_watches_in_group(self):
"""Tag intent propagates to every watch in the tag (no opt-in needed)."""
"""An opted-in tag's intent propagates to every watch in the tag."""
from changedetectionio.llm.evaluator import resolve_intent
tag = {'title': 'job-board', 'llm_intent': 'new engineering jobs'}
tag = _make_tag(title='job-board', llm_intent='new engineering jobs')
ds = _make_datastore(tags={'tag-1': tag})
# Three different watches, all in the tag, none have their own intent
@@ -103,6 +116,114 @@ class TestResolveIntent:
assert intent == ''
# ---------------------------------------------------------------------------
# The group's AI setting gates the whole cascade
# ---------------------------------------------------------------------------
class TestGroupAiSettingGatesTheCascade:
def test_only_the_on_state_hands_settings_down(self):
from changedetectionio.llm.evaluator import tag_llm_applies_to_watches
assert tag_llm_applies_to_watches(_make_tag(ai=True)) is True
# "Off" suppresses AI rather than lending prompts; "leave it to each watch" and tags
# predating the setting (and missing tags) hand nothing down either
assert tag_llm_applies_to_watches(_make_tag(ai=False)) is False
assert tag_llm_applies_to_watches(_make_tag(ai=None)) is False
assert tag_llm_applies_to_watches({'title': 'legacy'}) is False
assert tag_llm_applies_to_watches(None) is False
def test_intent_not_inherited_when_group_is_off(self):
from changedetectionio.llm.evaluator import resolve_intent
tag = _make_tag(ai=False, title='pricing-group', llm_intent='flag price drops')
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_intent='', tags=['tag-1'])
assert resolve_intent(watch, ds) == ('', '')
def test_field_not_inherited_when_group_is_off(self):
from changedetectionio.llm.evaluator import resolve_llm_field
tag = _make_tag(ai=False, llm_change_summary='list new events')
ds = _make_datastore(tags={'t1': tag})
watch = _make_watch(llm_change_summary='', tags=['t1'])
assert resolve_llm_field(watch, ds, 'llm_change_summary') == ('', '')
def test_summary_prompt_not_inherited_when_group_is_off(self):
from changedetectionio.llm.evaluator import get_effective_summary_prompt
tag = _make_tag(ai=False, llm_change_summary='TAG')
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
watch = _make_watch(llm_change_summary='', tags=['t1'])
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL'
def test_first_group_set_to_on_wins_over_an_earlier_one_that_is_off(self):
"""A group that isn't "On" is skipped entirely, not treated as "found, but empty"."""
from changedetectionio.llm.evaluator import resolve_intent
ds = _make_datastore(tags={
'ignored': _make_tag(ai=False, title='ignored-group', llm_intent='IGNORED'),
'used': _make_tag(ai=True, title='used-group', llm_intent='USED'),
})
watch = _make_watch(llm_intent='', tags=['ignored', 'used'])
assert resolve_intent(watch, ds) == ('USED', 'used-group')
# ---------------------------------------------------------------------------
# llm_enabled_for_watch — per-watch / per-group AI on-off switch (#4204)
# ---------------------------------------------------------------------------
class TestLlmEnabledForWatch:
def test_enabled_by_default(self):
"""Watches predating the switch (no key at all) keep AI on."""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = _make_datastore()
assert llm_enabled_for_watch(_make_watch(), ds) == (True, 'watch')
def test_watch_switch_off(self):
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = _make_datastore()
watch = _make_watch()
watch['llm_backend_profile'] = False
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
def test_group_switch_overrides_watch_off(self):
""""The group setting overrides any watch on/off" — group ON beats watch OFF."""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
tag = _make_tag(ai=True, title='ai-group')
ds = _make_datastore(tags={'t1': tag})
watch = _make_watch(tags=['t1'])
watch['llm_backend_profile'] = False
assert llm_enabled_for_watch(watch, ds) == (True, 'ai-group')
def test_group_switch_overrides_watch_on(self):
from changedetectionio.llm.evaluator import llm_enabled_for_watch
tag = _make_tag(ai=False, title='no-ai-group')
ds = _make_datastore(tags={'t1': tag})
watch = _make_watch(tags=['t1'])
watch['llm_backend_profile'] = True
assert llm_enabled_for_watch(watch, ds) == (False, 'no-ai-group')
def test_group_leaving_it_to_each_watch_does_not_decide(self):
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = _make_datastore(tags={'t1': _make_tag(ai=None, title='undecided-group')})
watch = _make_watch(tags=['t1'])
watch['llm_backend_profile'] = False
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
def test_group_without_the_key_leaves_it_to_the_watch(self):
"""Tags predating the setting behave like "leave it to each watch"."""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = _make_datastore(tags={'t1': {'title': 'legacy'}})
watch = _make_watch(tags=['t1'])
watch['llm_backend_profile'] = False
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
def test_first_deciding_group_wins(self):
"""An undecided group is skipped; the next group with an opinion decides."""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = _make_datastore(tags={
'a': _make_tag(ai=None, title='undecided-group'),
'b': _make_tag(ai=False, title='no-ai-group'),
})
watch = _make_watch(tags=['a', 'b'])
assert llm_enabled_for_watch(watch, ds) == (False, 'no-ai-group')
# ---------------------------------------------------------------------------
# get_llm_config
# ---------------------------------------------------------------------------
@@ -386,7 +507,7 @@ class TestTokenBudget:
class TestResolveLlmField:
def test_watch_value_takes_priority(self):
from changedetectionio.llm.evaluator import resolve_llm_field
tag = {'title': 'mygroup', 'llm_change_summary': 'tag summary prompt'}
tag = _make_tag(title='mygroup', llm_change_summary='tag summary prompt')
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_change_summary='watch summary prompt', tags=['tag-1'])
value, source = resolve_llm_field(watch, ds, 'llm_change_summary')
@@ -395,7 +516,7 @@ class TestResolveLlmField:
def test_tag_value_used_when_watch_empty(self):
from changedetectionio.llm.evaluator import resolve_llm_field
tag = {'title': 'events-group', 'llm_change_summary': 'list new events'}
tag = _make_tag(title='events-group', llm_change_summary='list new events')
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_change_summary='', tags=['tag-1'])
value, source = resolve_llm_field(watch, ds, 'llm_change_summary')
@@ -413,7 +534,7 @@ class TestResolveLlmField:
def test_works_for_llm_intent_field_too(self):
"""resolve_llm_field is generic — works for llm_intent same as llm_change_summary."""
from changedetectionio.llm.evaluator import resolve_llm_field
tag = {'title': 'grp', 'llm_intent': 'flag price drops'}
tag = _make_tag(llm_intent='flag price drops')
ds = _make_datastore(tags={'t1': tag})
watch = _make_watch(llm_intent='', tags=['t1'])
value, source = resolve_llm_field(watch, ds, 'llm_intent')
@@ -469,7 +590,7 @@ class TestSummariseChange:
def test_cascades_from_tag(self):
"""llm_change_summary on a tag propagates to watches in that tag."""
from changedetectionio.llm.evaluator import summarise_change
tag = {'title': 'events', 'llm_change_summary': 'Translate events to English'}
tag = _make_tag(title='events', llm_change_summary='Translate events to English')
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}, tags={'tag-1': tag})
watch = _make_watch(llm_change_summary='', tags=['tag-1'])
with patch('changedetectionio.llm.client.completion',
@@ -552,7 +673,7 @@ class TestSummaryCacheKey:
def test_get_effective_prompt_cascades_from_tag(self):
from changedetectionio.llm.evaluator import get_effective_summary_prompt
tag = {'title': 'grp', 'llm_change_summary': 'tag-level prompt'}
tag = _make_tag(llm_change_summary='tag-level prompt')
ds = _make_datastore(tags={'t1': tag})
watch = _make_watch(llm_change_summary='', tags=['t1'])
assert get_effective_summary_prompt(watch, ds) == 'tag-level prompt'
@@ -592,7 +713,7 @@ class TestSummaryPromptAppendMode:
def test_watch_append_targets_the_tag_prompt_when_a_tag_supplies_one(self):
"""The watch appends to what it would otherwise have inherited — here the tag."""
from changedetectionio.llm.evaluator import get_effective_summary_prompt
tag = {'title': 'grp', 'llm_change_summary': 'TAG'}
tag = _make_tag(llm_change_summary='TAG')
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
watch['llm_change_summary_mode'] = 'append'
@@ -600,7 +721,7 @@ class TestSummaryPromptAppendMode:
def test_tag_and_watch_can_both_append_forming_a_chain(self):
from changedetectionio.llm.evaluator import get_effective_summary_prompt
tag = {'title': 'grp', 'llm_change_summary': 'TAG', 'llm_change_summary_mode': 'append'}
tag = _make_tag(llm_change_summary='TAG', llm_change_summary_mode='append')
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
watch['llm_change_summary_mode'] = 'append'
@@ -608,7 +729,7 @@ class TestSummaryPromptAppendMode:
def test_tag_appends_while_watch_replaces(self):
from changedetectionio.llm.evaluator import get_effective_summary_prompt
tag = {'title': 'grp', 'llm_change_summary': 'TAG', 'llm_change_summary_mode': 'append'}
tag = _make_tag(llm_change_summary='TAG', llm_change_summary_mode='append')
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
assert get_effective_summary_prompt(watch, ds) == 'WATCH'
@@ -3,16 +3,37 @@ Unit tests for changedetectionio/llm/prompt_builder.py
All functions are pure — no external dependencies needed.
"""
import pytest
from changedetectionio.llm.prompt_builder import (
_annotate_moved_lines,
build_eval_prompt,
build_eval_system_prompt,
build_setup_prompt,
build_setup_system_prompt,
SNAPSHOT_CONTEXT_CHARS,
)
class TestAnnotateMovedLines:
def test_annotate_moved_lines_marks_reordered_content(self):
diff = "- Item Alpha\n+ Item Beta\n+ Item Alpha\n- Item Beta"
annotated = _annotate_moved_lines(diff)
assert "~ Item Alpha" in annotated
assert "~ Item Beta" in annotated
def test_annotate_standalone_timestamp_without_moved_lines(self):
# Even when there are NO moved lines, standalone relative timestamps must be annotated
diff = "- 2 hours ago\n+ 3 hours ago\n+ Genuine new article headline"
annotated = _annotate_moved_lines(diff)
assert "~ 2 hours ago" in annotated
assert "~ 3 hours ago" in annotated
assert "+ Genuine new article headline" in annotated
def test_unrelated_diff_remains_unchanged(self):
diff = "- Old price: $100\n+ New price: $80"
annotated = _annotate_moved_lines(diff)
assert annotated == diff
class TestBuildEvalPrompt:
def test_contains_intent(self):
prompt = build_eval_prompt(intent='Alert on price drops', diff='- $500\n+ $400')
@@ -3,10 +3,13 @@ Unit tests for changedetectionio/llm/response_parser.py
All functions are pure — no external dependencies needed.
"""
import pytest
from changedetectionio.llm.response_parser import (
_extract_json,
parse_eval_response,
parse_preview_response,
parse_setup_response,
)
@@ -37,6 +40,86 @@ class TestExtractJson:
result = _extract_json(raw)
assert '"important"' in result
def test_strips_reasoning_think_tags(self):
raw = (
'<think>\n'
'Let us consider if {"important": false} is right. Actually, yes.\n'
'</think>\n'
'{"important": true, "summary": "Price fell to $300"}'
)
result = _extract_json(raw)
assert result == '{"important": true, "summary": "Price fell to $300"}'
def test_strips_reasoning_think_tags_with_code_fence(self):
raw = (
'<think>\nThinking about the price change.\n</think>\n'
'```json\n{"important": false, "summary": "Cosmetic change only"}\n```'
)
result = _extract_json(raw)
assert '"important"' in result
assert '<think>' not in result
class TestReasoningBlockEdgeCases:
"""A reasoning scratchpad usually contains JSON of its own, so any leftover scratchpad
lets _extract_json return a discarded intermediate answer. Every shape below carries a
misleading `"important": false` in the scratchpad and the real verdict outside it."""
def test_closing_tag_only_is_still_stripped(self):
# Several providers/chat templates inject the opening tag themselves, so only the
# closer comes back over the wire.
raw = (
'My first read was {"important": false, "summary": "nothing"}\n'
'</think>\n'
'{"important": true, "summary": "Price dropped"}'
)
assert _extract_json(raw) == '{"important": true, "summary": "Price dropped"}'
assert parse_eval_response(raw) == {
'important': True,
'summary': 'Price dropped',
}
def test_thinking_tag_variant_is_stripped(self):
raw = (
'<thinking>weighing {"important": false, "summary": "no"}</thinking>\n'
'{"important": true, "summary": "Price dropped"}'
)
assert parse_eval_response(raw)['important'] is True
def test_multiple_reasoning_blocks_are_stripped(self):
raw = (
'<think>step one</think>'
'<think>{"important": false, "summary": "no"}</think>'
'{"important": true, "summary": "Price dropped"}'
)
assert parse_eval_response(raw)['important'] is True
def test_unterminated_reasoning_block_raises(self):
# Truncated by max_tokens mid-thought: the only JSON present is the abandoned guess,
# so returning it would silently invert the verdict. Raise instead and let
# evaluator.py's handler fall back to "important" rather than dropping the change.
raw = (
'<think>\n'
'First guess: {"important": false, "summary": "nothing"}\n'
'But actually the price dropped, so'
)
with pytest.raises(ValueError, match='unterminated reasoning block'):
_extract_json(raw)
def test_unterminated_block_propagates_out_of_parse_eval_response(self):
"""Deliberately NOT swallowed. parse_eval_response's own fallback is
important=False, which suppresses the notification - the opposite of what
evaluator.py wants on failure ("don't suppress the notification"). Letting
ValueError escape routes it to that handler instead. Do not add ValueError to
the except tuple in parse_eval_response."""
raw = '<think>truncated mid-thought {"important": false}'
with pytest.raises(ValueError):
parse_eval_response(raw)
def test_response_with_no_reasoning_block_is_untouched(self):
raw = '{"important": true, "summary": "plain"}'
assert _extract_json(raw) == raw
class TestParseEvalResponse:
def test_valid_important_true(self):
@@ -51,12 +134,36 @@ class TestParseEvalResponse:
assert result['important'] is False
assert 'date counter' in result['summary']
def test_string_false_evaluates_to_false(self):
raw = '{"important": "false", "summary": "No relevant changes found"}'
result = parse_eval_response(raw)
assert result['important'] is False
assert result['summary'] == 'No relevant changes found'
def test_string_true_evaluates_to_true(self):
raw = '{"important": "true", "summary": "Price updated"}'
result = parse_eval_response(raw)
assert result['important'] is True
assert result['summary'] == 'Price updated'
def test_markdown_fenced_response(self):
raw = '```json\n{"important": true, "summary": "New job posted"}\n```'
result = parse_eval_response(raw)
assert result['important'] is True
assert result['summary'] == 'New job posted'
def test_reasoning_model_response_parsed_correctly(self):
raw = (
'<think>\n'
'1. Checking diff: {"important": false} was our initial thought.\n'
'2. However the price dropped from $100 to $80.\n'
'</think>\n'
'{"important": true, "summary": "Price dropped by $20"}'
)
result = parse_eval_response(raw)
assert result['important'] is True
assert result['summary'] == 'Price dropped by $20'
def test_malformed_json_falls_back_to_safe_default(self):
result = parse_eval_response('this is not json at all')
assert result['important'] is False
@@ -71,6 +178,11 @@ class TestParseEvalResponse:
result = parse_eval_response(raw)
assert result['important'] is True
def test_falsy_integer_coerced_to_bool(self):
raw = '{"important": 0, "summary": "no"}'
result = parse_eval_response(raw)
assert result['important'] is False
def test_summary_stripped_of_whitespace(self):
raw = '{"important": false, "summary": " no match "}'
result = parse_eval_response(raw)
@@ -88,6 +200,32 @@ class TestParseEvalResponse:
assert result['summary'] == 'skip'
class TestParsePreviewResponse:
def test_valid_found_true(self):
raw = '{"found": true, "answer": "Price is $49.99"}'
result = parse_preview_response(raw)
assert result['found'] is True
assert result['answer'] == 'Price is $49.99'
def test_valid_found_false(self):
raw = '{"found": false, "answer": "Item not listed"}'
result = parse_preview_response(raw)
assert result['found'] is False
assert result['answer'] == 'Item not listed'
def test_string_false_in_preview(self):
raw = '{"found": "false", "answer": "Not found"}'
result = parse_preview_response(raw)
assert result['found'] is False
assert result['answer'] == 'Not found'
def test_preview_with_think_tags(self):
raw = '<think>Looking for price...</think>\n{"found": true, "answer": "$19.99"}'
result = parse_preview_response(raw)
assert result['found'] is True
assert result['answer'] == '$19.99'
class TestParseSetupResponse:
def test_no_prefilter_needed(self):
raw = '{"needs_prefilter": false, "selector": null, "reason": "intent is global"}'
@@ -95,8 +233,15 @@ class TestParseSetupResponse:
assert result['needs_prefilter'] is False
assert result['selector'] is None
def test_string_false_in_setup(self):
raw = '{"needs_prefilter": "false", "selector": null, "reason": "global"}'
result = parse_setup_response(raw)
assert result['needs_prefilter'] is False
def test_semantic_selector_accepted(self):
raw = '{"needs_prefilter": true, "selector": "footer", "reason": "intent references footer"}'
raw = (
'{"needs_prefilter": true, "selector": "footer", "reason": "intent references footer"}'
)
result = parse_setup_response(raw)
assert result['needs_prefilter'] is True
assert result['selector'] == 'footer'
@@ -84,10 +84,12 @@ def test_socks5(client, live_server, measure_memory_usage, datastore_path):
# PROXY CHECKER WIDGET CHECK - this needs more checking
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
res = client.get(
# POST only - it kicks off real fetches through every configured proxy
res = client.post(
url_for("check_proxies.start_check", uuid=uuid),
follow_redirects=True
)
assert res.status_code == 200
# It's probably already finished super fast :(
#assert b"RUNNING" in res.data
@@ -125,7 +125,7 @@ def test_check_access_control(app, client, live_server, measure_memory_usage, da
follow_redirects=True
)
res = c.get(url_for("logout"),
res = c.post(url_for("logout"),
follow_redirects=True)
assert b"Login" in res.data
@@ -79,26 +79,41 @@ def test_snapshot_refuses_browser_that_cannot_preview(client, live_server, measu
from changedetectionio.blueprint.add_watch_ui import browser_config
monkeypatch.setattr(browser_config, 'is_visual_capable', lambda name, datastore: False)
snapshot_url = url_for('add_watch_ui.add_watch_ui_snapshot')
# Nothing capable, and no explicit browser asked for -> nothing to preview with
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com'))
res = client.post(snapshot_url, data={'url': 'https://example.com'})
assert res.status_code == 400
assert b'No interactive browser' in res.data
# Explicitly asking for a browser that can't preview is refused just the same
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com',
fetch_backend='html_requests'))
res = client.post(snapshot_url, data={'url': 'https://example.com',
'fetch_backend': 'html_requests'})
assert res.status_code == 400
# A made-up name never resolves to a capable fetcher either (real capability lookup here)
monkeypatch.undo()
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com',
fetch_backend='../../etc/passwd'))
res = client.post(snapshot_url, data={'url': 'https://example.com',
'fetch_backend': '../../etc/passwd'})
assert res.status_code == 400
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com',
fetch_backend='os'))
res = client.post(snapshot_url, data={'url': 'https://example.com',
'fetch_backend': 'os'})
assert res.status_code == 400
def test_snapshot_is_post_only(client, live_server, measure_memory_usage, datastore_path):
"""A GET must not reach the endpoint at all.
/snapshot drives a real server-side browser fetch and hands the rendered result back in
the response (GHSA-56fq-63vj-9992). As a GET that is reachable by anything that can make
the operator's browser issue a request - an <img>/<iframe>/link from another site - with
no CSRF token in play. POST-only + CSRFProtect means only our own page can trigger it.
"""
# Method mismatch surfaces as 404 here rather than 405
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot') + '?url=https://example.com')
assert res.status_code in (404, 405)
def test_submit_rejects_unknown_fetcher(client, live_server, measure_memory_usage, datastore_path):
"""A posted browser is checked server side, so a doctored form can't pin a junk fetcher."""
datastore = _datastore(client)
+77
View File
@@ -321,3 +321,80 @@ def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path
date_created = res.json.get('date_created')
assert date_created != 454444444444, "ReadOnly date_created should not be updateable"
assert date_created != "454444444444", "ReadOnly date_created should not be updateable"
def test_api_watch_tag_field_accepts_names_and_uuids(client, live_server, measure_memory_usage, datastore_path):
"""The `tag` field on a watch takes tag *names*, `tags` takes UUIDs.
`tag` was documented as taking a UUID for years while the code fed it to add_tag(title),
so a UUID silently created a junk group *titled* with that UUID and never applied the tag
the caller asked for. Both spellings now resolve to the same tag.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
datastore = live_server.app.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
hdr = {'x-api-key': api_key, 'content-type': 'application/json'}
def titles_of(watch_uuid):
tags = datastore.data['settings']['application']['tags']
return sorted(tags[t].get('title') for t in datastore.data['watching'][watch_uuid].get('tags'))
# A name creates the group
res = client.post(url_for("createwatch"), data=json.dumps({"url": test_url, "tag": "helloworld"}), headers=hdr)
assert res.status_code == 201
assert titles_of(res.json['uuid']) == ['helloworld']
# An existing tag's UUID links to that tag rather than making a group named after the UUID
res = client.post(url_for("tag"), data=json.dumps({"title": "RealTag"}), headers=hdr)
assert res.status_code == 201
real_tag_uuid = res.json['uuid']
tag_count_before = len(datastore.data['settings']['application']['tags'])
res = client.post(url_for("createwatch"), data=json.dumps({"url": f"{test_url}?p=2", "tag": real_tag_uuid}), headers=hdr)
assert res.status_code == 201
assert real_tag_uuid in datastore.data['watching'][res.json['uuid']].get('tags')
assert titles_of(res.json['uuid']) == ['RealTag']
assert len(datastore.data['settings']['application']['tags']) == tag_count_before, "No junk tag titled with a UUID"
# `tags` with UUIDs keeps working
res = client.post(url_for("createwatch"), data=json.dumps({"url": f"{test_url}?p=3", "tags": [real_tag_uuid]}), headers=hdr)
assert res.status_code == 201
assert titles_of(res.json['uuid']) == ['RealTag']
# Names and UUIDs can be mixed, and blank entries from a trailing comma are dropped -
# add_tag() returns False for those and a falsy entry breaks every watch['tags'] lookup
res = client.post(url_for("createwatch"),
data=json.dumps({"url": f"{test_url}?p=4", "tag": f"Mixed,,{real_tag_uuid},"}), headers=hdr)
assert res.status_code == 201
assert titles_of(res.json['uuid']) == ['Mixed', 'RealTag']
assert all(datastore.data['watching'][res.json['uuid']].get('tags')), "No falsy entries in tags"
# A UUID that matches no tag is skipped rather than becoming a group named after it
unknown_uuid = '0be0272a-19dc-4c97-8aae-5a68df319489'
tag_count_before = len(datastore.data['settings']['application']['tags'])
res = client.post(url_for("createwatch"),
data=json.dumps({"url": f"{test_url}?p=5", "tag": unknown_uuid}), headers=hdr)
assert res.status_code == 201
assert datastore.data['watching'][res.json['uuid']].get('tags') == []
assert len(datastore.data['settings']['application']['tags']) == tag_count_before
# Names are matched case-insensitively against existing tags, as the spec claims
tag_count_before = len(datastore.data['settings']['application']['tags'])
res = client.post(url_for("createwatch"),
data=json.dumps({"url": f"{test_url}?p=6", "tag": "rEaLtAg"}), headers=hdr)
assert res.status_code == 201
assert datastore.data['watching'][res.json['uuid']].get('tags') == [real_tag_uuid]
assert len(datastore.data['settings']['application']['tags']) == tag_count_before, "Casing must not fork a second tag"
# `tags` is applied verbatim and never creates: an unknown UUID is stored as a dangling
# reference that simply resolves to no group. Documented, and harmless because the lookup
# is a dictfilt() over known tags - pinned here so changing it has to be deliberate.
bogus = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
tag_count_before = len(datastore.data['settings']['application']['tags'])
res = client.post(url_for("createwatch"),
data=json.dumps({"url": f"{test_url}?p=7", "tags": [bogus]}), headers=hdr)
assert res.status_code == 201
assert datastore.data['watching'][res.json['uuid']].get('tags') == [bogus]
assert len(datastore.data['settings']['application']['tags']) == tag_count_before
assert datastore.get_all_tags_for_watch(res.json['uuid']) == {}
assert client.get(url_for("watchlist.index")).status_code == 200, "A dangling tag ref must not break the list"
@@ -96,7 +96,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage
assert b'ldjson-price-track-offer' in res.data
# Accept it
client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True))
client.post(url_for('price_data_follower.accept', uuid=uuid), follow_redirects=True)
client.post(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# Offer should be gone
+2 -2
View File
@@ -24,7 +24,7 @@ def test_backup(client, live_server, measure_memory_usage, datastore_path):
wait_for_all_checks(client)
# Launch the thread in the background to create the backup
res = client.get(
res = client.post(
url_for("backups.request_backup"),
follow_redirects=True
)
@@ -136,7 +136,7 @@ def test_backup_restore(client, live_server, measure_memory_usage, datastore_pat
wait_for_all_checks(client)
# Create a full backup
client.get(url_for("backups.request_backup"), follow_redirects=True)
client.post(url_for("backups.request_backup"), follow_redirects=True)
time.sleep(4)
# Download the latest backup zip
@@ -493,7 +493,7 @@ def test_tag_mute_persists(client, live_server):
tag_uuid = datastore.add_tag('Test Tag')
# Mute the tag
response = client.get(url_for("tags.mute", uuid=tag_uuid))
response = client.post(url_for("tags.mute", uuid=tag_uuid))
assert response.status_code == 302 # Redirect
# Verify muted in memory
+21 -21
View File
@@ -11,7 +11,7 @@ def test_zh_TW(client, live_server, measure_memory_usage, datastore_path):
# Be sure we got a session cookie
res = client.get(url_for("watchlist.index"), follow_redirects=True)
res = client.get(
res = client.post(
url_for("set_language", locale="zh_Hant_TW"), # Traditional
follow_redirects=True
)
@@ -21,7 +21,7 @@ def test_zh_TW(client, live_server, measure_memory_usage, datastore_path):
assert '選擇語言'.encode() in res.data
# Check second set works
res = client.get(
res = client.post(
url_for("set_language", locale="en_GB"),
follow_redirects=True
)
@@ -30,7 +30,7 @@ def test_zh_TW(client, live_server, measure_memory_usage, datastore_path):
assert b"Select Language" in res.data, "Second set of language worked"
# Check arbitration between zh_Hant_TW<->zh
res = client.get(
res = client.post(
url_for("set_language", locale="zh"), # Simplified chinese
follow_redirects=True
)
@@ -89,7 +89,7 @@ def test_language_switching(client, live_server, measure_memory_usage, datastore
client.get(url_for("add_watch_ui.add_watch_ui_index"), follow_redirects=True)
# Step 1: Set the language to Italian using the /set-language endpoint
res = client.get(
res = client.post(
url_for("set_language", locale="it"),
follow_redirects=True
)
@@ -119,7 +119,7 @@ def test_language_switching(client, live_server, measure_memory_usage, datastore
# NB: use 'en_GB' not 'en' — only the variants are in language_codes; the
# plain 'en' code is silently rejected by set_language and the locale would
# remain at 'it', defeating the round-trip assertion below.
res = client.get(
res = client.post(
url_for("set_language", locale="en_GB"),
follow_redirects=True
)
@@ -152,7 +152,7 @@ def test_invalid_locale(client, live_server, measure_memory_usage, datastore_pat
# bare 'en' is NOT in language_codes and is silently rejected by
# set_language, so passing it here would leave the session locale unset
# and let the (unrelated) Accept-Language fallback decide what renders.
res = client.get(
res = client.post(
url_for("set_language", locale="en_GB"),
follow_redirects=True
)
@@ -160,7 +160,7 @@ def test_invalid_locale(client, live_server, measure_memory_usage, datastore_pat
assert res.status_code == 200
# Try to set an invalid locale
res = client.get(
res = client.post(
url_for("set_language", locale="invalid_locale_xyz"),
follow_redirects=True
)
@@ -190,7 +190,7 @@ def test_language_persistence_in_session(client, live_server, measure_memory_usa
client.get(url_for("watchlist.index"), follow_redirects=True)
# Set language to Italian
res = client.get(
res = client.post(
url_for("set_language", locale="it"),
follow_redirects=True
)
@@ -215,7 +215,7 @@ def test_language_persistence_in_session(client, live_server, measure_memory_usa
assert sess.get('locale') == 'it', "Locale should be set in session"
# Call auto-detect to clear the locale
res = client.get(
res = client.post(
url_for("ui.delete_locale_language_session_var_if_it_exists"),
follow_redirects=True
)
@@ -254,7 +254,7 @@ def test_set_language_with_redirect(client, live_server, measure_memory_usage, d
client.get(url_for("watchlist.index"), follow_redirects=True)
# Set language with a redirect parameter (simulating language change from /settings)
res = client.get(
res = client.post(
url_for("set_language", locale="de", redirect="/settings"),
follow_redirects=False
)
@@ -268,7 +268,7 @@ def test_set_language_with_redirect(client, live_server, measure_memory_usage, d
assert sess.get('locale') == 'de'
# Test with invalid locale (should still redirect safely)
res = client.get(
res = client.post(
url_for("set_language", locale="invalid_locale", redirect="/settings"),
follow_redirects=False
)
@@ -276,7 +276,7 @@ def test_set_language_with_redirect(client, live_server, measure_memory_usage, d
assert '/settings' in res.location
# Test with malicious redirect (should default to watchlist)
res = client.get(
res = client.post(
url_for("set_language", locale="en", redirect="https://evil.com"),
follow_redirects=False
)
@@ -296,7 +296,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
client.get(url_for("watchlist.index"), follow_redirects=True)
# Test Italian translations
res = client.get(url_for("set_language", locale="it"), follow_redirects=True)
res = client.post(url_for("set_language", locale="it"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
@@ -312,7 +312,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
# Test Korean translations
res = client.get(url_for("set_language", locale="ko"), follow_redirects=True)
res = client.post(url_for("set_language", locale="ko"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
@@ -332,7 +332,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
# Test Chinese Simplified translations
res = client.get(url_for("set_language", locale="zh"), follow_redirects=True)
res = client.post(url_for("set_language", locale="zh"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
@@ -348,7 +348,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
# Test German translations
res = client.get(url_for("set_language", locale="de"), follow_redirects=True)
res = client.post(url_for("set_language", locale="de"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
@@ -363,7 +363,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
# Test Russian translations
res = client.get(url_for("set_language", locale="ru"), follow_redirects=True)
res = client.post(url_for("set_language", locale="ru"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
@@ -378,7 +378,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
# Test Traditional Chinese (zh_Hant_TW) translations
res = client.get(url_for("set_language", locale="zh_Hant_TW"), follow_redirects=True)
res = client.post(url_for("set_language", locale="zh_Hant_TW"), follow_redirects=True)
assert res.status_code == 200
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
@@ -627,7 +627,7 @@ def test_session_locale_overrides_accept_language(client, live_server, measure_m
"Expected Taiwan flag 'fi fi-tw' from auto-detect"
# Step 2: User explicitly selects Korean language
res = client.get(
res = client.post(
url_for("set_language", locale="ko"),
headers={'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.8'}, # Browser still sends zh-TW
follow_redirects=True
@@ -700,7 +700,7 @@ def test_clear_history_translated_confirmation(client, live_server, measure_memo
wait_for_all_checks(client)
# Set language to German
res = client.get(
res = client.post(
url_for("set_language", locale="de"),
follow_redirects=True
)
@@ -726,7 +726,7 @@ def test_clear_history_translated_confirmation(client, live_server, measure_memo
"German confirmation word 'loschen' should be accepted (issue #3865)"
# Switch back to English and verify English word still works
res = client.get(
res = client.post(
url_for("set_language", locale="en_US"),
follow_redirects=True
)
+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' %}}")
@@ -158,6 +158,35 @@ def test_lone_surrogate_escapes_do_not_break_filters():
assert '\U0001F600' in text
text.encode('utf-8')
# The surrogate can just as easily land in an object *key*, which has to be sanitized too -
# otherwise the whole-document re-serialization still trips over it
lone_key = '{"ti' + BS + 'uD800tle": "value", "other": "untouched"}'
for f in ("json:$.other", "jq:.other", "jqraw:.other") if jq_support else ("json:$.other",):
text = html_tools.extract_json_as_string(lone_key, f)
assert "untouched" in text
text.encode('utf-8')
# Dumping the whole document exercises the key path directly
for f in ("json:$", "jq:.") if jq_support else ("json:$",):
text = html_tools.extract_json_as_string(lone_key, f)
assert "value" in text
assert not any(0xD800 <= ord(c) <= 0xDFFF for c in text)
text.encode('utf-8')
# Nested arrays and non-string scalars have to be walked too. The scalars come first
# deliberately: detection short-circuits on the first surrogate it finds, so anything after
# the offending value would never be visited.
nested = ('{"count": 3, "flag": null, "ok": true, '
'"items": ["clean", "bad' + BS + 'uD800", [1, "deep' + BS + 'uDC00"]]}')
for f in ("json:$", "jq:.") if jq_support else ("json:$",):
text = html_tools.extract_json_as_string(nested, f)
assert "clean" in text and "deep" in text
assert not any(0xD800 <= ord(c) <= 0xDFFF for c in text)
text.encode('utf-8')
# ...and the scalars must survive the round-trip unmangled
assert html_tools.extract_json_as_string(nested, "json:$.count").strip() == "3"
def test_unittest_inline_extract_body():
content = """
@@ -25,7 +25,7 @@ def test_language_endpoints_work_for_anonymous_users(client, live_server, measur
follow_redirects=True)
assert res.status_code == 200
client.get(url_for("logout"), follow_redirects=True)
client.post(url_for("logout"), follow_redirects=True)
# Both language links are rendered on the login page, so both must be reachable
res = client.get(url_for("login"))
@@ -33,13 +33,13 @@ def test_language_endpoints_work_for_anonymous_users(client, live_server, measur
assert b'language-selector' in res.data, "Language modal trigger should render for anonymous users"
# Picking a specific language must not redirect to the login page
res = client.get(url_for("set_language", locale="de"), follow_redirects=False)
res = client.post(url_for("set_language", locale="de"), follow_redirects=False)
assert res.status_code == 302
assert '/login' not in res.headers.get("Location", ""), \
"set_language must not bounce anonymous users to /login"
# ...and neither must clearing it back to auto-detect
res = client.get(url_for("ui.delete_locale_language_session_var_if_it_exists"), follow_redirects=False)
res = client.post(url_for("ui.delete_locale_language_session_var_if_it_exists"), follow_redirects=False)
assert res.status_code == 302
assert '/login' not in res.headers.get("Location", ""), \
"Auto-detect must not bounce anonymous users to /login (it renders on the login page)"
@@ -393,12 +393,12 @@ def test_llm_models_endpoint_blocks_private_api_base(
def test_llm_test_endpoint_blocks_private_api_base(
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""GET /settings/llm/test must refuse api_base pointing at private/loopback
"""POST /settings/llm/test must refuse api_base pointing at private/loopback
hosts and must never reach litellm.completion()."""
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
for bad in _SSRF_PRIVATE_HOSTS:
res = client.get(
res = client.post(
url_for('settings.llm.llm_test'),
query_string={'model': 'openai/gpt-4', 'api_base': bad},
)
@@ -530,7 +530,7 @@ def test_llm_test_refuses_to_leak_stored_key_to_different_api_base(
monkeypatch.setattr(llm_client, 'completion',
lambda **kw: calls.append(kw) or ('', 0, 0, 0))
res = client.get(
res = client.post(
url_for('settings.llm.llm_test'),
query_string={
'model': 'gpt-4o-mini',
@@ -75,9 +75,10 @@ def test_llm_change_summary_cascades_from_tag(
_set_response(datastore_path, HTML_V1)
test_url = url_for('test_endpoint', _external=True)
# Create a tag with llm_change_summary
# Create a tag with llm_change_summary, AI set to On so its watches inherit it
tag_uuid = ds.add_tag('events-group')
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Summarise new events'
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
# Watch in that tag, no own summary prompt
uuid = ds.add_watch(url=test_url)
@@ -281,6 +282,7 @@ def test_tag_prompt_overrides_global_default(
tag_uuid = ds.add_tag('my-group')
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Tag: bullet points.'
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
uuid = ds.add_watch(url='https://example.com')
watch = ds.data['watching'][uuid]
@@ -306,6 +308,7 @@ def test_watch_prompt_overrides_tag_and_global(
tag_uuid = ds.add_tag('my-group')
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Tag prompt.'
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
uuid = ds.add_watch(url='https://example.com')
watch = ds.data['watching'][uuid]
@@ -1,18 +1,27 @@
#!/usr/bin/env python3
"""
Tests for group/tag LLM field overrides on the watch edit page.
Tests for the AI/LLM settings a group hands to its watches.
When a watch's first linked tag has llm_intent or llm_change_summary set
and the watch itself has no own value, the watch edit form should render
the relevant textarea as readonly with a "From group '<name>': <value>"
placeholder.
A group has exactly ONE AI control on its edit page — the ternary llm_backend_profile
("AI for watches in this group"):
When the watch has its own value, the textarea is editable as normal.
On the group's llm_intent / llm_change_summary apply to every watch in
it (unless the watch fills in its own), and AI is on for all of them
Off AI off for every watch in the group; its prompts are never used
Leave it to each the group has no say; each watch's own AI settings apply
The evaluator cascade (resolve_llm_field) is already tested in the
evaluator unit tests; these tests focus on the UI and form behaviour.
Only "On" makes anything cascade, so it decides both:
* whether the evaluator inherits the group's prompts (resolve_llm_field /
get_effective_summary_prompt), and
* whether the watch edit page shows the inherited value as a
"From group '<name>': <value>" placeholder.
So every UI assertion here is paired: group On → "From group ..." visible, group Off or
undecided → not a trace of it. On a watch the same field is a plain on/off checkbox (#4204).
"""
import html
import json
from flask import url_for
@@ -20,6 +29,42 @@ from flask import url_for
from changedetectionio.tests.util import live_server_setup, delete_all_watches
# The exact rendered string under test, from templates/edit/include_llm_intent.html:
# {% set intent_placeholder = _("From group '%(name)s': %(value)s", ...) %}
def _from_group_text(name, value):
return f"From group '{name}': {value}"
def _page_text(res):
"""Response body with HTML entities resolved, so we can match the placeholder as written."""
return html.unescape(res.data.decode('utf-8', errors='replace'))
def _input_tags(body, name):
"""Every whole <input ...> tag carrying name="<name>", in document order."""
tags = []
pos = body.find(f'name="{name}"')
while pos != -1:
start = body.rfind('<input', 0, pos)
end = body.find('>', pos)
tags.append(body[start:end + 1])
pos = body.find(f'name="{name}"', end)
return tags
def _input_tag(body, name):
"""Return the first <input ...> tag carrying name="<name>", or '' if there isn't one."""
tags = _input_tags(body, name)
return tags[0] if tags else ''
def _checkbox_is_checked(body, name):
"""True when that checkbox renders as checked (attribute order is not guaranteed)."""
tag = _input_tag(body, name)
assert tag, f'no <input name="{name}"> in the page'
return 'checked' in tag
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -51,14 +96,20 @@ def _api_token(client):
# Tag setup
# ---------------------------------------------------------------------------
def _add_tag_with_llm(datastore, title, llm_intent='', llm_change_summary=''):
"""Create a tag with LLM fields set directly in the datastore."""
def _add_tag_with_llm(datastore, title, llm_intent='', llm_change_summary='', ai=True):
"""Create a tag with LLM fields set directly in the datastore.
`ai` is the group's single AI control (llm_backend_profile): True = On, False = Off,
None = leave it to each watch. Defaults to True because most cases here are about what
a group set to "On" hands down.
"""
tag_uuid = datastore.add_tag(title)
tag = datastore.data['settings']['application']['tags'][tag_uuid]
if llm_intent:
tag['llm_intent'] = llm_intent
if llm_change_summary:
tag['llm_change_summary'] = llm_change_summary
tag['llm_backend_profile'] = ai
return tag_uuid
@@ -72,16 +123,123 @@ def _link_watch_to_tag(datastore, watch_uuid, tag_uuid):
# ---------------------------------------------------------------------------
# Watch edit page — llm_intent group override
# The group's one AI control — ternary on a group, checkbox on a watch
# ---------------------------------------------------------------------------
def test_watch_edit_shows_llm_intent_placeholder_from_group(
def test_group_edit_page_has_the_ternary_ai_control(
client, live_server, measure_memory_usage, datastore_path):
"""
When a watch has no own llm_intent but its first tag does,
the edit page must show "From group" + group name + group value in the
placeholder so the user sees the inherited value but can still type to override.
The field must NOT be readonly.
The group edit page must offer all three states — without them there is no way to turn
group-wide AI settings on, or to switch AI off for a whole group (#4204).
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
tag_uuid = ds.add_tag('Ternary Group')
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
assert 'name="llm_backend_profile"' in body, \
"group edit page is missing the 'AI for watches in this group' control"
for value in ('true', 'false', 'none'):
assert f'name="llm_backend_profile" value="{value}"' in body, \
f"group AI control is missing its '{value}' option"
assert 'AI for watches in this group' in text
assert 'Leave it to each watch' in text
# New groups start undecided, so they never touch their watches
assert 'id="llm_backend_profile_none" checked' in body, \
"a new group must default to 'Leave it to each watch'"
delete_all_watches(client)
def test_watch_edit_page_has_a_plain_ai_checkbox(
client, live_server, measure_memory_usage, datastore_path):
"""A watch gets a simple on/off, not the group's three-way choice."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
assert 'name="llm_intent"' in body # AI section is rendered...
assert 'type="checkbox"' in _input_tag(body, 'llm_backend_profile')
assert 'Leave it to each watch' not in _page_text(res), \
"the group's three-way AI choice must not appear on a watch"
delete_all_watches(client)
def test_group_edit_page_never_shows_the_from_group_placeholder(
client, live_server, measure_memory_usage, datastore_path):
"""
"From group ..." describes something inherited by a watch; the group edit page is
where the value is authored, so it must show group-flavoured copy instead.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
tag_uuid = _add_tag_with_llm(ds, 'Authoring Group', llm_intent='Group intent value')
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
assert res.status_code == 200
text = _page_text(res)
assert 'From group' not in text
# Group copy, not the per-watch copy (both live in the same shared include)
assert 'Set a change intent for all watches in this tag/group' in text
assert 'Describe what you care about' not in text
delete_all_watches(client)
def test_group_edit_form_saves_and_reloads_each_ai_state(
client, live_server, measure_memory_usage, datastore_path):
"""All three states round-trip through the real form, and the prompt is kept regardless."""
res = client.post(url_for('tags.form_tag_add'), data={'name': 'Saved Group'}, follow_redirects=True)
assert b'Tag added' in res.data
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
tag_uuid = list(ds.data['settings']['application']['tags'].keys())[0]
for posted, expected in (('true', True), ('false', False), ('none', None)):
res = client.post(
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
data={'title': 'Saved Group',
'llm_intent': 'Only notify me about price drops',
'llm_backend_profile': posted},
follow_redirects=True,
)
assert b'Updated' in res.data
tag = ds.data['settings']['application']['tags'][tag_uuid]
assert tag.get('llm_backend_profile') is expected, f"posting {posted!r} should store {expected!r}"
# The prompt is always kept — the AI state only decides whether it is used
assert tag.get('llm_intent') == 'Only notify me about price drops'
# ..and the reloaded page comes back on the same option
body = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid)).data.decode('utf-8', errors='replace')
assert f'id="llm_backend_profile_{posted}" checked' in body, \
f"saved state {posted!r} must render as the selected option"
delete_all_watches(client)
# ---------------------------------------------------------------------------
# Watch edit page — llm_intent group override, gated on the checkbox
# ---------------------------------------------------------------------------
def test_watch_edit_shows_llm_intent_placeholder_when_group_overrides_enabled(
client, live_server, measure_memory_usage, datastore_path):
"""
Group override ON + watch has no own llm_intent → the edit page shows
"From group '<name>': <value>" as the placeholder, and the field stays editable.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
@@ -89,34 +247,64 @@ def test_watch_edit_shows_llm_intent_placeholder_from_group(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Price Watchers', llm_intent='Notify only when price drops')
tag_uuid = _add_tag_with_llm(ds, 'Price Watchers',
llm_intent='Notify only when price drops',
ai=True)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
assert 'name="llm_intent"' in body
# Placeholder must contain "From group", the tag name, and the value
assert 'From group' in body
assert 'Price Watchers' in body
assert 'Notify only when price drops' in body
assert 'name="llm_intent"' in text
assert _from_group_text('Price Watchers', 'Notify only when price drops') in text, \
"watch edit must show the inherited group intent as a 'From group ...' placeholder"
# Field must be editable — no readonly attribute
intent_pos = body.find('name="llm_intent"')
snippet = body[max(0, intent_pos - 50): intent_pos + 300]
intent_pos = text.find('name="llm_intent"')
snippet = text[max(0, intent_pos - 50): intent_pos + 300]
assert 'readonly' not in snippet, \
f"llm_intent must be editable when group sets it; snippet: {snippet!r}"
delete_all_watches(client)
def test_watch_edit_hides_llm_intent_placeholder_when_group_overrides_disabled(
client, live_server, measure_memory_usage, datastore_path):
"""
Same group, same intent, checkbox OFF → no "From group ..." anywhere, and the
generic example placeholder is used instead. This is the pairing that makes the
checkbox meaningful.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Price Watchers',
llm_intent='Notify only when price drops',
ai=False)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
text = _page_text(res)
assert 'From group' not in text, \
"group AI settings must not leak into the watch unless the group is set to On"
assert 'Notify only when price drops' not in text
# Falls back to the normal per-watch example placeholder
assert 'e.g. Alert me when the price drops below $300' in text
delete_all_watches(client)
def test_watch_edit_llm_intent_shows_own_value_not_group_placeholder(
client, live_server, measure_memory_usage, datastore_path):
"""
When a watch has its own llm_intent, the textarea body shows the watch's value
and the placeholder does NOT say "From group" (the group value is irrelevant).
When the watch has its own llm_intent, the textarea body shows the watch's value
and the placeholder does NOT say "From group" — even with the group opted in.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
@@ -124,33 +312,32 @@ def test_watch_edit_llm_intent_shows_own_value_not_group_placeholder(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Deals Group', llm_intent='Tag intent: notify on any deal')
tag_uuid = _add_tag_with_llm(ds, 'Deals Group',
llm_intent='Tag intent: notify on any deal',
ai=True)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
ds.data['watching'][watch_uuid]['llm_intent'] = 'My own watch intent'
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
# Watch's own value in the textarea body
assert 'My own watch intent' in body
assert 'My own watch intent' in text
# No group placeholder — the watch has its own value
assert 'From group' not in body
assert 'From group' not in text
delete_all_watches(client)
# ---------------------------------------------------------------------------
# Watch edit page — llm_change_summary group override
# Watch edit page — llm_change_summary group override, gated on the checkbox
# ---------------------------------------------------------------------------
def test_watch_edit_shows_llm_change_summary_placeholder_from_group(
def test_watch_edit_shows_llm_change_summary_placeholder_when_group_overrides_enabled(
client, live_server, measure_memory_usage, datastore_path):
"""
When a watch has no own llm_change_summary but its first tag does,
the edit page shows the group value as placeholder (editable, not readonly).
"""
"""Group override ON → the group summary prompt shows as placeholder (editable)."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
@@ -159,31 +346,58 @@ def test_watch_edit_shows_llm_change_summary_placeholder_from_group(
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(
ds, 'Summary Group',
llm_change_summary='List new items as bullet points. Translate to English.'
llm_change_summary='List new items as bullet points. Translate to English.',
ai=True,
)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
assert 'Summary Group' in body
assert 'List new items as bullet points' in body
assert _from_group_text('Summary Group',
'List new items as bullet points. Translate to English.') in text
# Field must be editable
summary_pos = body.find('name="llm_change_summary"')
summary_pos = text.find('name="llm_change_summary"')
assert summary_pos != -1
snippet = body[max(0, summary_pos - 50): summary_pos + 300]
snippet = text[max(0, summary_pos - 50): summary_pos + 300]
assert 'readonly' not in snippet, \
f"llm_change_summary must be editable; snippet: {snippet!r}"
delete_all_watches(client)
def test_watch_edit_hides_llm_change_summary_placeholder_when_group_overrides_disabled(
client, live_server, measure_memory_usage, datastore_path):
"""Group override OFF → no "From group ..." for the summary prompt either."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(
ds, 'Summary Group',
llm_change_summary='List new items as bullet points. Translate to English.',
ai=False,
)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
text = _page_text(res)
assert 'From group' not in text
assert 'List new items as bullet points' not in text
delete_all_watches(client)
def test_watch_edit_llm_change_summary_shows_own_value_not_group_placeholder(
client, live_server, measure_memory_usage, datastore_path):
"""
When a watch has its own llm_change_summary, the textarea body shows the watch's
When the watch has its own llm_change_summary, the textarea body shows the watch's
value and no group placeholder appears.
"""
ds = client.application.config.get('DATASTORE')
@@ -192,17 +406,18 @@ def test_watch_edit_llm_change_summary_shows_own_value_not_group_placeholder(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Summary Group', llm_change_summary='Tag summary prompt')
tag_uuid = _add_tag_with_llm(ds, 'Summary Group', llm_change_summary='Tag summary prompt',
ai=True)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
ds.data['watching'][watch_uuid]['llm_change_summary'] = 'My own summary prompt'
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
assert 'My own summary prompt' in body
assert 'From group' not in body
assert 'My own summary prompt' in text
assert 'From group' not in text
delete_all_watches(client)
@@ -230,8 +445,7 @@ def test_watch_edit_no_tag_fields_are_editable(
# Neither textarea should be readonly
for field in ('llm_intent', 'llm_change_summary'):
pos = body.find(f'name="{field}"')
if pos == -1:
continue # field might not render if LLM section hidden for some reason
assert pos != -1, f"{field} textarea missing from watch edit page"
snippet = body[max(0, pos - 50): pos + 300]
assert 'readonly' not in snippet, \
f"{field} textarea must not be readonly with no tags; snippet: {snippet!r}"
@@ -242,14 +456,14 @@ def test_watch_edit_no_tag_fields_are_editable(
# ---------------------------------------------------------------------------
# Evaluator cascade — group value used when watch has none
# Evaluator cascade — gated on the same checkbox as the UI
# ---------------------------------------------------------------------------
def test_resolve_llm_field_uses_tag_value_when_watch_has_none(
def test_resolve_llm_field_uses_tag_value_when_group_overrides_enabled(
client, live_server, measure_memory_usage, datastore_path):
"""
resolve_llm_field returns the tag's value (and tag name as source) when
the watch has no own value.
resolve_llm_field returns the tag's value (and tag name as source) when the watch
has no own value and the group is opted in.
"""
from changedetectionio.llm.evaluator import resolve_llm_field
@@ -258,7 +472,8 @@ def test_resolve_llm_field_uses_tag_value_when_watch_has_none(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Cascade Group', llm_intent='Group-level intent')
tag_uuid = _add_tag_with_llm(ds, 'Cascade Group', llm_intent='Group-level intent',
ai=True)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
watch = ds.data['watching'][watch_uuid]
@@ -270,10 +485,33 @@ def test_resolve_llm_field_uses_tag_value_when_watch_has_none(
delete_all_watches(client)
def test_resolve_llm_field_ignores_tag_value_when_group_overrides_disabled(
client, live_server, measure_memory_usage, datastore_path):
"""The UI hint and the evaluator agree: no opt-in, no inheritance."""
from changedetectionio.llm.evaluator import resolve_llm_field
ds = client.application.config.get('DATASTORE')
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Cascade Group', llm_intent='Group-level intent',
ai=False)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
watch = ds.data['watching'][watch_uuid]
value, source = resolve_llm_field(watch, ds, 'llm_intent')
assert value == ''
assert source == ''
delete_all_watches(client)
def test_resolve_llm_field_uses_watch_value_over_tag(
client, live_server, measure_memory_usage, datastore_path):
"""
resolve_llm_field prefers the watch's own value over the tag's.
resolve_llm_field prefers the watch's own value over the tag's, opted in or not.
"""
from changedetectionio.llm.evaluator import resolve_llm_field
@@ -282,7 +520,8 @@ def test_resolve_llm_field_uses_watch_value_over_tag(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Override Group', llm_intent='Tag intent')
tag_uuid = _add_tag_with_llm(ds, 'Override Group', llm_intent='Tag intent',
ai=True)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
ds.data['watching'][watch_uuid]['llm_intent'] = 'Watch-level intent'
@@ -303,8 +542,8 @@ def test_resolve_llm_field_uses_watch_value_over_tag(
def test_watch_edit_independent_field_overrides(
client, live_server, measure_memory_usage, datastore_path):
"""
llm_intent can come from a group (readonly) while llm_change_summary
is editable (watch has its own), and vice versa.
llm_intent can be inherited from an opted-in group while llm_change_summary
is the watch's own value.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
@@ -316,6 +555,7 @@ def test_watch_edit_independent_field_overrides(
ds, 'Mixed Group',
llm_intent='Group intent here',
llm_change_summary='Group summary here',
ai=True,
)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
@@ -324,21 +564,22 @@ def test_watch_edit_independent_field_overrides(
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
# llm_intent: group placeholder visible (watch has no own value)
assert 'Group intent here' in body
intent_pos = body.find('name="llm_intent"')
assert _from_group_text('Mixed Group', 'Group intent here') in text
intent_pos = text.find('name="llm_intent"')
assert intent_pos != -1
intent_snippet = body[max(0, intent_pos - 50): intent_pos + 300]
intent_snippet = text[max(0, intent_pos - 50): intent_pos + 300]
assert 'readonly' not in intent_snippet, \
f"llm_intent must be editable even when group sets it; snippet: {intent_snippet!r}"
# llm_change_summary: watch own value shown in body, no group placeholder
assert 'My own summary' in body
summary_pos = body.find('name="llm_change_summary"')
# llm_change_summary: watch own value shown in body, no group placeholder for it
assert 'My own summary' in text
assert _from_group_text('Mixed Group', 'Group summary here') not in text
summary_pos = text.find('name="llm_change_summary"')
assert summary_pos != -1
summary_snippet = body[max(0, summary_pos - 50): summary_pos + 300]
summary_snippet = text[max(0, summary_pos - 50): summary_pos + 300]
assert 'readonly' not in summary_snippet, \
f"llm_change_summary should be editable; snippet: {summary_snippet!r}"
@@ -409,7 +650,7 @@ def test_tag_edit_page_shows_prompt_mode_radio(
def test_tag_append_mode_persists_and_applies(
client, live_server, measure_memory_usage, datastore_path):
"""A group set to append adds its text to the global prompt for its watches."""
"""An opted-in group set to append adds its text to the global prompt for its watches."""
from changedetectionio.llm.evaluator import get_effective_summary_prompt
ds = client.application.config.get('DATASTORE')
@@ -420,7 +661,8 @@ def test_tag_append_mode_persists_and_applies(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Append Group', llm_change_summary='Group extra line.')
tag_uuid = _add_tag_with_llm(ds, 'Append Group', llm_change_summary='Group extra line.',
ai=True)
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary_mode'] = 'append'
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
@@ -428,3 +670,348 @@ def test_tag_append_mode_persists_and_applies(
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL RULES\n\nGroup extra line.'
delete_all_watches(client)
def test_tag_append_mode_ignored_when_group_overrides_disabled(
client, live_server, measure_memory_usage, datastore_path):
"""Without the opt-in the group's appended text never reaches the effective prompt."""
from changedetectionio.llm.evaluator import get_effective_summary_prompt
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
ds.data['settings']['application']['llm']['change_summary_default'] = 'GLOBAL RULES'
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Append Group', llm_change_summary='Group extra line.',
ai=False)
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary_mode'] = 'append'
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
watch = ds.data['watching'][watch_uuid]
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL RULES'
delete_all_watches(client)
# ---------------------------------------------------------------------------
# AI on/off per watch, and per group when the group overrides — #4204
# ---------------------------------------------------------------------------
def test_watch_edit_has_ai_enabled_checkbox(
client, live_server, measure_memory_usage, datastore_path):
"""Every watch gets its own AI on/off switch, on by default."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
body = res.data.decode('utf-8', errors='replace')
assert 'name="llm_backend_profile"' in body, \
"watch edit page is missing the AI on/off checkbox (#4204)"
assert _checkbox_is_checked(body, 'llm_backend_profile'), \
"AI should default to on for a new watch"
# No group involved, so no override note
assert 'overrides this' not in _page_text(res)
delete_all_watches(client)
def test_group_edit_can_switch_ai_off_for_the_whole_group(
client, live_server, measure_memory_usage, datastore_path):
"""The group's control has an explicit "Off for every watch" state — the #4204 ask."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
tag_uuid = ds.add_tag('AI Toggle Group')
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
assert 'Off for every watch' in _page_text(res), \
"group edit page cannot switch AI off for all of its watches (#4204)"
delete_all_watches(client)
def test_group_edit_greys_out_the_prompts_unless_ai_is_on(
client, live_server, measure_memory_usage, datastore_path):
"""
Same cue as the restock group override (#overrides_watch + toggleOpacity): the prompts
only mean something in the "On" state, so they are greyed out otherwise. The state
changes without a reload, so this pins the JS wiring rather than the opacity value.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
tag_uuid = ds.add_tag('Dimmed Group')
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
body = res.data.decode('utf-8', errors='replace')
assert "toggleOpacityByRadioValue('llm_backend_profile', 'true'" in body, \
"group edit page lost the wiring that greys out the AI prompts"
# ..and the elements it drives are all present
for element_id in ('llm_backend_profile_true', 'change-intent-notify-me-when', 'change-summary'):
assert f'id="{element_id}"' in body, f"#{element_id} missing — opacity toggle would be a no-op"
delete_all_watches(client)
def test_watch_edit_shows_which_group_decided_the_ai_switch(
client, live_server, measure_memory_usage, datastore_path):
"""
A group that has taken the decision decides for its watches, so the watch edit page says
which group is in charge and what it decided — and that explanation must stay readable
(only the checkbox it describes is dimmed).
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
tag_uuid = _add_tag_with_llm(ds, 'Tech news', ai=False)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
text = _page_text(res)
assert 'decides this: AI is OFF for every watch in that group.' in text
# The group name links to that group's edit page, straight to its AI tab
tag_edit_url = url_for('tags.form_tag_edit', uuid=tag_uuid)
assert f'<a href="{tag_edit_url}#ai-llm">Tech news</a>' in text, \
"the group name in the note must link to the group's edit page"
# The note itself is not inside the dimmed wrapper
note_pos = text.find('decides this: AI is OFF')
dimmed_pos = text.find('style="opacity: 0.6;"', text.find('id="llm-ai-enabled-row"'))
assert dimmed_pos != -1, "the overridden checkbox should be dimmed"
assert text.find('</div>', dimmed_pos) < note_pos, \
"the 'Group X decides this' note must not be greyed out with the checkbox"
# Group switched to On → the note reflects that, still linked
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
text = _page_text(client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid)))
assert 'decides this: AI is ON for every watch in that group.' in text
assert f'<a href="{tag_edit_url}#ai-llm">Tech news</a>' in text
# ..and the watch's own checkbox is disabled, showing what the group decided
body = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid)).data.decode('utf-8', errors='replace')
checkbox = _input_tags(body, 'llm_backend_profile')[0]
assert 'disabled' in checkbox, "the group decides, so the watch's own checkbox must be disabled"
assert 'checked' in checkbox, "disabled checkbox must show the state the group decided (ON)"
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = False
body = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid)).data.decode('utf-8', errors='replace')
checkbox = _input_tags(body, 'llm_backend_profile')[0]
assert 'disabled' in checkbox and 'checked' not in checkbox, \
"disabled checkbox must show the state the group decided (OFF)"
# ..and with the group leaving it to each watch, the watch is on its own again
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = None
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert 'decides this' not in _page_text(res)
delete_all_watches(client)
def test_watch_own_ai_switch_survives_being_overridden_by_a_group(
client, live_server, measure_memory_usage, datastore_path):
"""
While a group decides, the watch's checkbox is disabled — and a disabled checkbox is not
POSTed, which for a checkbox reads as "off". Saving the watch must therefore NOT quietly
rewrite its own preference: it has to come back unchanged once the group stops deciding.
"""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
# Watch says AI on (the default); the group overrules it with "off"
tag_uuid = _add_tag_with_llm(ds, 'Deciding Group', ai=False)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
assert ds.data['watching'][watch_uuid].get('llm_backend_profile') is True
assert llm_enabled_for_watch(ds.data['watching'][watch_uuid], ds) == (False, 'Deciding Group')
# Save the page exactly as the browser would: the disabled checkbox sends nothing at all
res = client.post(
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
data={'url': test_url, 'fetch_backend': 'html_requests',
'time_between_check_use_default': 'y'},
follow_redirects=True,
)
assert b'Updated watch' in res.data
watch = ds.data['watching'][watch_uuid]
assert watch.get('llm_backend_profile') is True, \
"saving while a group decides must not overwrite the watch's own AI preference"
# Not even a hand-crafted POST can write it while it isn't user-editable
res = client.post(
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
data={'url': test_url, 'fetch_backend': 'html_requests',
'time_between_check_use_default': 'y', 'llm_backend_profile': ''},
follow_redirects=True,
)
assert b'Updated watch' in res.data
watch = ds.data['watching'][watch_uuid]
assert watch.get('llm_backend_profile') is True
# The group still wins for now...
assert llm_enabled_for_watch(watch, ds) == (False, 'Deciding Group')
# ..and when the group stops deciding, the watch's untouched preference applies again
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = None
assert llm_enabled_for_watch(watch, ds) == (True, 'watch')
delete_all_watches(client)
def test_watch_ai_switch_saves_via_edit_form(
client, live_server, measure_memory_usage, datastore_path):
"""Turning AI off on a watch persists, and turning it back on works."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
from changedetectionio.llm.evaluator import llm_enabled_for_watch
# Unchecked checkbox is simply absent from the POST
res = client.post(
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
data={'url': test_url, 'fetch_backend': 'html_requests',
'time_between_check_use_default': 'y'},
follow_redirects=True,
)
assert b'Updated watch' in res.data
watch = ds.data['watching'][watch_uuid]
assert watch.get('llm_backend_profile') is False
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
res = client.post(
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
data={'url': test_url, 'fetch_backend': 'html_requests',
'time_between_check_use_default': 'y', 'llm_backend_profile': 'y'},
follow_redirects=True,
)
assert b'Updated watch' in res.data
watch = ds.data['watching'][watch_uuid]
assert watch.get('llm_backend_profile') is True
assert llm_enabled_for_watch(watch, ds) == (True, 'watch')
delete_all_watches(client)
def test_group_ai_switch_saves_and_decides_for_its_watches(
client, live_server, measure_memory_usage, datastore_path):
"""
Group form set to "Off for every watch" → every watch in the group is off, whatever the
watch itself says. This is the #4204 "turn AI off for a whole group" flow.
"""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
res = client.post(url_for('tags.form_tag_add'), data={'name': 'Budget Group'}, follow_redirects=True)
assert b'Tag added' in res.data
tag_uuid = [u for u, t in ds.data['settings']['application']['tags'].items()
if t.get('title') == 'Budget Group'][0]
res = client.post(
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
data={'title': 'Budget Group', 'llm_backend_profile': 'false'},
follow_redirects=True,
)
assert b'Updated' in res.data
tag = ds.data['settings']['application']['tags'][tag_uuid]
assert tag.get('llm_backend_profile') is False
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
watch = ds.data['watching'][watch_uuid]
assert watch.get('llm_backend_profile') is True # the watch itself still says "on"
assert llm_enabled_for_watch(watch, ds) == (False, 'Budget Group'), \
"a group set to Off must win over the watch's own AI switch"
delete_all_watches(client)
def test_group_ai_state_survives_a_save_with_no_llm_configured(
client, live_server, measure_memory_usage, datastore_path):
"""
With no provider configured the AI control isn't rendered, and an unrendered control is
indistinguishable from "off" in a POST — so the page must carry the saved state in a
hidden input, otherwise merely saving the group would switch AI off.
"""
ds = client.application.config.get('DATASTORE')
# deliberately NOT calling _configure_llm
res = client.post(url_for('tags.form_tag_add'), data={'name': 'Unconfigured Group'}, follow_redirects=True)
assert b'Tag added' in res.data
tag_uuid = [u for u, t in ds.data['settings']['application']['tags'].items()
if t.get('title') == 'Unconfigured Group'][0]
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
body = res.data.decode('utf-8', errors='replace')
assert 'name="llm_intent"' not in body, "AI fields should not render without a provider"
hidden = _input_tag(body, 'llm_backend_profile')
assert 'type="hidden"' in hidden and 'value="true"' in hidden, \
"the group AI state must be preserved in a hidden input when the AI section is not rendered"
# Submit exactly what that page would send
res = client.post(
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
data={'title': 'Unconfigured Group', 'llm_backend_profile': 'true'},
follow_redirects=True,
)
assert b'Updated' in res.data
tag = ds.data['settings']['application']['tags'][tag_uuid]
assert tag.get('llm_backend_profile') is True, \
"saving with the AI section hidden must not switch AI off"
delete_all_watches(client)
# ---------------------------------------------------------------------------
# End-to-end through the real forms — the path the user actually clicks
# ---------------------------------------------------------------------------
def test_group_override_round_trip_through_both_forms(
client, live_server, measure_memory_usage, datastore_path):
"""
Set the group to On with an intent via the group form, tag a watch with it, and the watch
edit page shows the inherited value as its placeholder. This is the flow that was broken:
the group had no way to switch group-wide AI settings on at all.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
res = client.post(url_for('tags.form_tag_add'), data={'name': 'E2E Group'}, follow_redirects=True)
assert b'Tag added' in res.data
tag_uuid = [u for u, t in ds.data['settings']['application']['tags'].items()
if t.get('title') == 'E2E Group'][0]
res = client.post(
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
data={'title': 'E2E Group',
'llm_intent': 'Only tell me about stock changes',
'llm_backend_profile': 'true'},
follow_redirects=True,
)
assert b'Updated' in res.data
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert _from_group_text('E2E Group', 'Only tell me about stock changes') in _page_text(res)
delete_all_watches(client)
@@ -38,7 +38,7 @@ def test_rss_tag_feed_ignores_security_token(client, live_server, datastore_path
wait_for_all_checks(client)
# Logout
client.get(url_for("logout"), follow_redirects=True)
client.post(url_for("logout"), follow_redirects=True)
# Request the tag RSS feed WITH the token
res = client.get(
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""
Regression tests for two scheduler/timezone bugs.
BUG 1 - edit page returned HTTP 500
ui/edit.py resolved the default timezone with
.get('scheduler_timezone_default', os.getenv('TZ', 'UTC'))
but App.py initialises that key to None, so the key EXISTS and dict.get()
never uses its fallback. None reached is_within_schedule(), which does
`tz_name.strip()` -> AttributeError -> the handler did `return False` -> Flask
raised TypeError ("view function did not return a valid response ... it was a
bool") -> HTTP 500. The watch was actually saved, so the user saw a 500 on a
successful save.
BUG 2 - ticker thread died silently
Same root cause, but flask_app.py's `return False` was inside the ticker
thread's main loop. One watch with an unresolvable timezone ended the thread
and NO watch was ever checked again until restart, with the process still up.
Run: pytest changedetectionio/tests/test_scheduler_timezone_regressions.py
"""
import time
from flask import url_for
from .util import live_server_setup, wait_for_all_checks, set_original_response
DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
def _schedule_form(url, enabled_days=('monday',)):
data = {
"url": url,
"fetch_backend": "html_requests",
"time_between_check_use_default": "",
"time_between_check-seconds": 1,
"time_schedule_limit-enabled": 'y',
}
for day in DAYS:
data[f"time_schedule_limit-{day}-start_time"] = "00:00"
data[f"time_schedule_limit-{day}-duration-hours"] = 24
data[f"time_schedule_limit-{day}-duration-minutes"] = 0
if day in enabled_days:
data[f"time_schedule_limit-{day}-enabled"] = 'y'
return data
def _full_schedule(enabled=True, timezone=None):
sched = {'enabled': True}
for day in DAYS:
sched[day] = {'enabled': enabled, 'start_time': '00:00',
'duration': {'hours': '24', 'minutes': '00'}}
if timezone is not None:
sched['timezone'] = timezone
return sched
def test_edit_page_saves_when_no_default_timezone_configured(client, live_server, measure_memory_usage, datastore_path):
"""BUG 1: enabling the scheduler with scheduler_timezone_default unset 500'd."""
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
datastore = live_server.app.config['DATASTORE']
# This is the default state from model/App.py - key present, value None.
datastore.data['settings']['application']['scheduler_timezone_default'] = None
uuid = datastore.add_watch(url=test_url)
wait_for_all_checks(client)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data=_schedule_form(test_url),
follow_redirects=True,
)
assert res.status_code == 200, f"expected 200, got {res.status_code}"
assert b"Internal Server Error" not in res.data
assert b"Updated watch." in res.data
# and the schedule really was saved
assert datastore.data['watching'][uuid]['time_schedule_limit']['enabled'] is True
def test_edit_page_saves_with_unresolvable_timezone(client, live_server, measure_memory_usage, datastore_path):
"""A garbage timezone must degrade to 'skip the recheck', not 500."""
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
datastore = live_server.app.config['DATASTORE']
datastore.data['settings']['application']['scheduler_timezone_default'] = 'Not/ARealZone'
uuid = datastore.add_watch(url=test_url)
wait_for_all_checks(client)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data=_schedule_form(test_url),
follow_redirects=True,
)
assert res.status_code == 200, f"expected 200, got {res.status_code}"
assert b"Updated watch." in res.data
def test_ticker_survives_a_watch_with_a_broken_timezone(client, live_server, measure_memory_usage, datastore_path):
"""
BUG 2: one bad watch used to kill the ticker thread, stopping every other
watch. Prove a healthy watch still gets checked alongside a broken one.
"""
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
datastore = live_server.app.config['DATASTORE']
datastore.data['settings']['application']['scheduler_timezone_default'] = None
broken_uuid = datastore.add_watch(url=test_url)
healthy_uuid = datastore.add_watch(url=test_url)
wait_for_all_checks(client)
# Broken watch: scheduler on, timezone that arrow cannot resolve
datastore.data['watching'][broken_uuid]['time_between_check_use_default'] = False
datastore.data['watching'][broken_uuid]['time_schedule_limit'] = _full_schedule(
timezone='Not/ARealZone'
)
# Healthy watch: no schedule limit, short interval so the ticker must pick it up
datastore.data['watching'][healthy_uuid]['time_between_check_use_default'] = False
datastore.data['watching'][healthy_uuid]['time_between_check'] = {
'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': 2
}
before = datastore.data['watching'][healthy_uuid]['last_checked']
# Give the ticker several passes over the broken watch
deadline = time.time() + 30
while time.time() < deadline:
if datastore.data['watching'][healthy_uuid]['last_checked'] != before:
break
time.sleep(0.5)
assert datastore.data['watching'][healthy_uuid]['last_checked'] != before, (
"the healthy watch was never rechecked - the ticker thread most likely "
"died on the broken watch's timezone"
)
def test_api_rejects_invalid_timezone_on_update(client, live_server, measure_memory_usage, datastore_path):
"""
The edit form runs validateTimeZoneName, but the API did not - the OpenAPI
schema for time_schedule_limit declares no `timezone` property and does not
set additionalProperties:false, so any string was accepted and stored.
A bogus zone makes arrow.now(tz) raise inside the scheduler.
"""
import json
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
datastore = live_server.app.config['DATASTORE']
api_key = datastore.data['settings']['application'].get('api_access_token')
uuid = datastore.add_watch(url=test_url)
wait_for_all_checks(client)
res = client.put(
url_for("watch", uuid=uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({'time_schedule_limit': _full_schedule(timezone='Not/ARealZone')}),
)
assert res.status_code == 400, f"expected 400, got {res.status_code}: {res.data}"
assert b'not a valid timezone' in res.data.lower()
stored = datastore.data['watching'][uuid]['time_schedule_limit']
assert stored.get('timezone') != 'Not/ARealZone', "invalid timezone was persisted anyway"
def test_api_accepts_valid_timezone_on_update(client, live_server, measure_memory_usage, datastore_path):
"""The validation must not block legitimate zones."""
import json
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
datastore = live_server.app.config['DATASTORE']
api_key = datastore.data['settings']['application'].get('api_access_token')
uuid = datastore.add_watch(url=test_url)
wait_for_all_checks(client)
for tz in ('Europe/Berlin', 'UTC', 'Pacific/Kiritimati'):
res = client.put(
url_for("watch", uuid=uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({'time_schedule_limit': _full_schedule(timezone=tz)}),
)
assert res.status_code == 200, f"{tz} rejected: {res.data}"
assert datastore.data['watching'][uuid]['time_schedule_limit']['timezone'] == tz
# omitting the timezone entirely stays valid
res = client.put(
url_for("watch", uuid=uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({'time_schedule_limit': _full_schedule()}),
)
assert res.status_code == 200, res.data
def test_api_rejects_invalid_timezone_on_create(client, live_server, measure_memory_usage, datastore_path):
"""Same guard on POST /watch, otherwise it is just a different door."""
import json
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
datastore = live_server.app.config['DATASTORE']
api_key = datastore.data['settings']['application'].get('api_access_token')
res = client.post(
url_for("createwatch"),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({'url': test_url,
'time_schedule_limit': _full_schedule(timezone='Bogus/Zone')}),
)
assert res.status_code == 400, f"expected 400, got {res.status_code}: {res.data}"
assert b'not a valid timezone' in res.data.lower()
def test_ignore_text_via_selection_marks_watch_edited(client, live_server, measure_memory_usage, datastore_path):
"""
The selection UI appended to ignore_text in place. That bypasses
watch_base.__setitem__, so was_edited stayed False and the
'content unchanged since last check' skip stayed active - the new
ignore_text would not take effect until the page changed on its own.
"""
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
datastore = live_server.app.config['DATASTORE']
uuid = datastore.add_watch(url=test_url)
wait_for_all_checks(client)
watch = datastore.data['watching'][uuid]
watch.reset_watch_edited_flag()
assert watch.was_edited is False, "precondition: flag should start clear"
res = client.post(
url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid),
data={'mode': 'exact', 'selection': 'Which is across multiple lines'},
follow_redirects=True,
)
assert res.status_code == 200
watch = datastore.data['watching'][uuid]
assert 'Which is across multiple lines' in watch['ignore_text'], "the text was not stored"
assert watch.was_edited is True, (
"watch was not flagged as edited - the new ignore_text will not be applied "
"until the page content changes on its own"
)
def test_ignore_text_digit_regex_marks_watch_edited(client, live_server, measure_memory_usage, datastore_path):
"""Same for the digit-regex branch."""
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
datastore = live_server.app.config['DATASTORE']
uuid = datastore.add_watch(url=test_url)
wait_for_all_checks(client)
datastore.data['watching'][uuid].reset_watch_edited_flag()
res = client.post(
url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid),
data={'mode': 'digit-regex', 'selection': 'Updated 1234 times'},
follow_redirects=True,
)
assert res.status_code == 200
watch = datastore.data['watching'][uuid]
assert any(t.startswith('/') and t.endswith('/') for t in watch['ignore_text']), \
f"expected a regex entry, got {watch['ignore_text']}"
assert watch.was_edited is True
def test_ignore_text_appends_to_existing(client, live_server, measure_memory_usage, datastore_path):
"""Rebinding must preserve entries already there, not replace them."""
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
datastore = live_server.app.config['DATASTORE']
uuid = datastore.add_watch(url=test_url)
wait_for_all_checks(client)
datastore.data['watching'][uuid]['ignore_text'] = ['already here']
client.post(
url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid),
data={'mode': 'exact', 'selection': 'line one\nline two'},
follow_redirects=True,
)
ignore_text = datastore.data['watching'][uuid]['ignore_text']
assert 'already here' in ignore_text, "existing entries were lost"
assert 'line one' in ignore_text
assert 'line two' in ignore_text
+5 -5
View File
@@ -440,7 +440,7 @@ def test_login_redirect_with_password(client, live_server, measure_memory_usage,
assert b"evil.com" not in res.data
# Logout for cleanup
client.get(url_for("logout"))
client.post(url_for("logout"))
# Test 5: Incorrect password with redirect should stay on login page
res = client.post(
@@ -483,7 +483,7 @@ def test_login_redirect_from_protected_page(client, live_server, measure_memory_
client.application.config['DATASTORE'].data['settings']['application']['password'] = salted_pass
# Logout to ensure we're not authenticated
client.get(url_for("logout"))
client.post(url_for("logout"))
# Try to access a protected page (edit page for first watch)
res = client.get(
@@ -524,7 +524,7 @@ def test_login_redirect_from_protected_page(client, live_server, measure_memory_
assert b'Edit' in res.data or b'Watching' in res.data
# Cleanup
client.get(url_for("logout"))
client.post(url_for("logout"))
del client.application.config['DATASTORE'].data['settings']['application']['password']
@@ -554,7 +554,7 @@ def test_logout_with_redirect(client, live_server, measure_memory_usage, datasto
assert res.status_code == 200
# Now logout with a redirect parameter (simulating logout from /settings)
res = client.get(
res = client.post(
url_for("logout", redirect="/settings"),
follow_redirects=False
)
@@ -961,7 +961,7 @@ def test_ghsa_8757_69j2_hx56_backup_restore_history_path_traversal(client, live_
wait_for_all_checks(client)
# Download a legitimate backup to use as a template
client.get(url_for("backups.request_backup"), follow_redirects=True)
client.post(url_for("backups.request_backup"), follow_redirects=True)
time.sleep(4)
res = client.get(url_for("backups.download_backup", filename="latest"), follow_redirects=True)
assert res.content_type == "application/zip"
+1 -1
View File
@@ -33,7 +33,7 @@ def test_share_watch(client, live_server, measure_memory_usage, datastore_path):
assert bytes(include_filters.encode('utf-8')) in res.data
# click share the link
res = client.get(
res = client.post(
url_for("ui.form_share_put_watch", uuid=uuid),
follow_redirects=True
)
@@ -0,0 +1,181 @@
"""Guards for the translation overlay layer (changedetectionio/translations_overlay).
The overlay is a second gettext tree merged on top of ``changedetectionio/translations``, letting a
deployment reword individual strings without editing the ``_()`` call site. See that directory's
README.md. Three separate things can break it, so there is a test for each.
1. Overlay entries key on the exact upstream msgid. When a string is reworded upstream the override
stops matching and silently reverts to upstream wording - no error, no log entry.
``test_overlay_catalogs_are_valid`` makes that a build failure.
2. The layering relies on Flask-Babel merging catalogs with ``dict.update`` semantics (later
directory wins per-message). Were a Flask-Babel upgrade to change that to an ``add_fallback``
chain, overrides would stop applying while everything still looked fine.
``test_overlay_overrides_a_string_in_a_rendered_page`` pins it against a real rendered page.
3. The directory has to actually reach ``BABEL_TRANSLATION_DIRECTORIES``.
``test_overlay_dir_*`` cover the wiring in flask_app.py, including the env var.
"""
import os
import subprocess
import sys
import pytest
from babel.messages.catalog import Catalog
from babel.messages.mofile import write_mo
from flask import url_for
PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REPO_ROOT = os.path.dirname(PKG_DIR)
OVERLAY_DIR = os.path.join(PKG_DIR, 'translations_overlay')
BASE_DIR = os.path.join(PKG_DIR, 'translations')
MANAGE = os.path.join(OVERLAY_DIR, 'manage.py')
# A msgid that renders as a settings-page tab label. The test asserts it is present *before*
# overriding it, so a rename upstream fails loudly rather than making the test silently vacuous.
OVERRIDDEN_MSGID = 'Global Filters'
SENTINEL = 'zzOverlaySentinelFiltersZZ'
def _write_mo(root, locale, entries):
"""Write a compiled catalog at <root>/<locale>/LC_MESSAGES/messages.mo."""
catalog = Catalog(locale=locale, domain='messages')
for msgid, msgstr in entries.items():
catalog.add(msgid, msgstr)
mo_dir = os.path.join(root, locale, 'LC_MESSAGES')
os.makedirs(mo_dir, exist_ok=True)
with open(os.path.join(mo_dir, 'messages.mo'), 'wb') as fp:
write_mo(fp, catalog)
def _import_app_with(env_overlay_dir):
"""Import flask_app in a clean subprocess and report its BABEL_TRANSLATION_DIRECTORIES.
Has to be a subprocess: the config is built at module import time, so it cannot be re-evaluated
under a different environment once flask_app is already in sys.modules.
"""
env = dict(os.environ, TRANSLATION_OVERLAY_DIR=env_overlay_dir)
result = subprocess.run(
[
sys.executable,
'-c',
'from changedetectionio import flask_app;'
'print("DIRS=" + flask_app.app.config["BABEL_TRANSLATION_DIRECTORIES"])',
],
capture_output=True,
text=True,
env=env,
cwd=REPO_ROOT,
)
assert result.returncode == 0, f"importing flask_app failed:\n{result.stdout}\n{result.stderr}"
line = [l for l in result.stdout.splitlines() if l.startswith('DIRS=')]
assert line, f"no config line in output:\n{result.stdout}\n{result.stderr}"
return line[0][len('DIRS=') :].split(';')
# ---------------------------------------------------------------------------
# 1. The overlay catalogs shipped in this repo are internally consistent
# ---------------------------------------------------------------------------
@pytest.mark.skipif(
not os.path.isdir(OVERLAY_DIR), reason='no translation overlay in this deployment'
)
def test_overlay_catalogs_are_valid():
"""Every override must still match an upstream msgid, be non-empty, and be compiled.
A failure here usually means upstream edited a string the overlay overrides. Re-copy the new
msgid verbatim from translations/messages.pot into the overlay catalog, then recompile with
`python changedetectionio/translations_overlay/manage.py compile`.
"""
result = subprocess.run([sys.executable, MANAGE, 'check'], capture_output=True, text=True)
assert result.returncode == 0, (
f"translation overlay is invalid:\n{result.stdout}{result.stderr}"
)
@pytest.mark.skipif(
not os.path.isdir(OVERLAY_DIR), reason='no translation overlay in this deployment'
)
def test_overlay_locales_have_a_base_catalog():
"""An overlay for a locale the app does not ship never loads, so it is silently dead."""
for locale in sorted(os.listdir(OVERLAY_DIR)):
if not os.path.isfile(os.path.join(OVERLAY_DIR, locale, 'LC_MESSAGES', 'messages.po')):
continue
assert os.path.isdir(os.path.join(BASE_DIR, locale)), (
f"overlay locale {locale!r} has no base catalog in translations/"
)
# ---------------------------------------------------------------------------
# 2. The merge actually happens, end to end, on a real page
# ---------------------------------------------------------------------------
def test_overlay_overrides_a_string_in_a_rendered_page(client, live_server, tmp_path):
"""A real overlay catalog changes real rendered output, and only the string it names."""
app = client.application
# The rest of this test injects its own directory, which would still pass if flask_app.py had
# stopped configuring the real one. Tie the two together so that regression fails here too.
if os.path.isdir(OVERLAY_DIR):
configured = app.config['BABEL_TRANSLATION_DIRECTORIES'].split(';')
assert OVERLAY_DIR in configured, (
f"{OVERLAY_DIR} exists but is not in BABEL_TRANSLATION_DIRECTORIES ({configured})"
)
baseline = client.get(url_for('settings.settings_page'))
assert baseline.status_code == 200
assert OVERRIDDEN_MSGID.encode() in baseline.data, (
f"{OVERRIDDEN_MSGID!r} no longer renders on the settings page - this test needs a new msgid"
)
assert SENTINEL.encode() not in baseline.data
overlay = tmp_path / 'overlay'
# en_GB is BABEL_DEFAULT_LOCALE, and the test client sends no Accept-Language header
_write_mo(str(overlay), 'en_GB', {OVERRIDDEN_MSGID: SENTINEL})
# The default Domain delegates to the app-level directory list, and caches per (locale, domain),
# so both have to be touched for a new catalog to be picked up mid-process.
dirs = app.extensions['babel'].translation_directories
domain_cache = app.extensions['babel'].instance.domain_instance.cache
dirs.append(str(overlay))
domain_cache.clear()
try:
overridden = client.get(url_for('settings.settings_page'))
assert overridden.status_code == 200
assert SENTINEL.encode() in overridden.data, (
'overlay catalog did not override the base catalog'
)
assert OVERRIDDEN_MSGID.encode() not in overridden.data, 'base wording is still rendering'
# Neighbouring tab label, deliberately not in the overlay - merging must not drop it
assert b'UI Options' in overridden.data, (
'overlay replaced the catalog instead of merging into it'
)
finally:
dirs.remove(str(overlay))
domain_cache.clear()
restored = client.get(url_for('settings.settings_page'))
assert SENTINEL.encode() not in restored.data
assert OVERRIDDEN_MSGID.encode() in restored.data, 'base wording did not come back'
# ---------------------------------------------------------------------------
# 3. flask_app.py wires the directory up, and stays a no-op when there isn't one
# ---------------------------------------------------------------------------
def test_overlay_dir_from_env_var_is_used(tmp_path):
overlay = tmp_path / 'my-overlay'
overlay.mkdir()
dirs = _import_app_with(str(overlay))
assert dirs[-1] == str(overlay), f"TRANSLATION_OVERLAY_DIR not appended, got {dirs}"
assert dirs[0] == BASE_DIR, 'base catalog must stay first so the overlay wins on conflicts'
def test_missing_overlay_dir_is_a_noop(tmp_path):
"""No overlay directory means the config is exactly what it was before the feature existed."""
dirs = _import_app_with(str(tmp_path / 'does-not-exist'))
assert dirs == [BASE_DIR], f"expected only the base catalog, got {dirs}"
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""PAGE_WATCH_LIMIT - the optional cap on how many watches one instance will hold.
The limit is enforced in datastore.add_watch(), which every add path funnels through, but each
surface has to report it in its own terms: a flash for the UI, a 429 for the API, one flash for
a whole file in the importers, and nothing at all (just a None) where there's no request context.
"""
import json
from flask import url_for
from .util import delete_all_watches
def test_watch_limit_absent_or_junk_means_unlimited(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""No env var, an empty one, or an unparseable one all leave the limit switched off."""
datastore = live_server.app.config['DATASTORE']
monkeypatch.delenv('PAGE_WATCH_LIMIT', raising=False)
assert datastore.watch_limit is None
assert datastore.watch_limit_reached() is False
# Junk must not block every add, and must not raise
monkeypatch.setenv('PAGE_WATCH_LIMIT', 'not-a-number')
assert datastore.watch_limit is None
assert datastore.watch_limit_reached() is False
# Set-but-empty is the same as unset
monkeypatch.setenv('PAGE_WATCH_LIMIT', '')
assert datastore.watch_limit is None
assert datastore.watch_limit_reached() is False
def test_api_create_watch_refused_at_limit(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
datastore = live_server.app.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
monkeypatch.setenv('PAGE_WATCH_LIMIT', '1')
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 201
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": f"{test_url}?second=1"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 429
assert b'Watch limit reached (1/1 watches)' in res.data
assert len(datastore.data['watching']) == 1
delete_all_watches(client)
def test_api_import_refuses_the_whole_batch(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""A 429 from import always means nothing was created, so the same request can be retried."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
datastore = live_server.app.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
headers = {'x-api-key': api_key, 'content-type': 'text/plain'}
monkeypatch.setenv('PAGE_WATCH_LIMIT', '3')
res = client.post(url_for("import"), data=f"{test_url}?a=1\n{test_url}?a=2", headers=headers)
assert res.status_code == 200
assert len(res.json) == 2
# Two more would make four - refused whole rather than importing the one that fits
res = client.post(url_for("import"), data=f"{test_url}?a=3\n{test_url}?a=4", headers=headers)
assert res.status_code == 429
assert b'would exceed it' in res.data
assert len(datastore.data['watching']) == 2
# The one that does fit still goes in
res = client.post(url_for("import"), data=f"{test_url}?a=3", headers=headers)
assert res.status_code == 200
assert len(datastore.data['watching']) == 3
delete_all_watches(client)
def test_quick_watch_add_refused_at_limit(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
datastore = live_server.app.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
monkeypatch.setenv('PAGE_WATCH_LIMIT', '1')
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, 'tags': ''},
follow_redirects=True
)
assert b'Watch added' in res.data
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": f"{test_url}?second=1", 'tags': ''},
follow_redirects=True
)
assert b'Watch limit reached (1/1 watches)' in res.data
assert b'Watch added' not in res.data
assert len(datastore.data['watching']) == 1
delete_all_watches(client)
def test_ui_import_reports_limit_once_and_hands_back_the_rest(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""The importer stops at the limit instead of letting add_watch() flash per remaining row."""
datastore = live_server.app.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
monkeypatch.setenv('PAGE_WATCH_LIMIT', '2')
urls = "\n".join(f"{test_url}?i={i}" for i in range(5))
res = client.post(url_for("imports.import_page"), data={"urls": urls}, follow_redirects=True)
assert res.data.count(b'Watch limit reached') == 1, "The limit should be reported once for the file, not once per row"
assert len(datastore.data['watching']) == 2
# 3 unprocessed URLs come back in the textarea to retry once there's room
assert b'3 Skipped' in res.data
assert b'i=4' in res.data
delete_all_watches(client)
def test_clone_refused_at_limit(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""Clone used to raise KeyError(None) here, then redirect to an edit page for uuid=None."""
datastore = live_server.app.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
uuid = datastore.add_watch(url=test_url)
monkeypatch.setenv('PAGE_WATCH_LIMIT', '1')
res = client.post(url_for("ui.form_clone", uuid=uuid), follow_redirects=True)
assert res.status_code == 200
assert b'Watch limit reached (1/1 watches)' in res.data
assert b'Cloned' not in res.data
assert len(datastore.data['watching']) == 1
delete_all_watches(client)
def test_over_limit_instance_still_loads_and_edits(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""A limit set below what an install already holds must only block *new* watches.
Everything already there keeps loading from disk and stays editable - the limit is not
retroactive and never hides or drops a watch.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
datastore = live_server.app.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
monkeypatch.delenv('PAGE_WATCH_LIMIT', raising=False)
uuids = [datastore.add_watch(url=f"{test_url}?i={i}") for i in range(3)]
assert all(uuids)
# Now cap it well below what's already stored
monkeypatch.setenv('PAGE_WATCH_LIMIT', '1')
assert datastore.watch_limit_reached() is True
# Re-reading from disk is not gated by the limit
datastore._load_watches()
assert len(datastore.data['watching']) == 3
# Still all listed
assert client.get(url_for("watchlist.index")).status_code == 200
res = client.get(url_for("createwatch"), headers={'x-api-key': api_key})
assert len(res.json) == 3
# And still editable
res = client.put(
url_for("watch", uuid=uuids[0]),
data=json.dumps({"title": "Still editable"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 200
assert datastore.data['watching'][uuids[0]].get('title') == "Still editable"
# Only adding is refused
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": f"{test_url}?new=1"}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
assert res.status_code == 429
assert len(datastore.data['watching']) == 3
delete_all_watches(client)
def test_limit_shown_in_settings_info_tab(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""The Info tab names the limit only when one is configured."""
monkeypatch.delenv('PAGE_WATCH_LIMIT', raising=False)
res = client.get(url_for("settings.settings_page"))
assert b'Maximum number of page watches' not in res.data
monkeypatch.setenv('PAGE_WATCH_LIMIT', '42')
res = client.get(url_for("settings.settings_page"))
assert b'Maximum number of page watches' in res.data
assert b'42' in res.data
def test_limit_reported_without_a_request_context(client, live_server, measure_memory_usage, datastore_path, monkeypatch, mocker):
"""The CLI (-u) and the API's background import thread call add_watch() with no request
context, where flash() raises RuntimeError instead of reporting anything."""
datastore = live_server.app.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
monkeypatch.setenv('PAGE_WATCH_LIMIT', '1')
assert datastore.add_watch(url=test_url)
# pytest-flask pushes a request context around every test, so take it away
mocker.patch('changedetectionio.store.has_request_context', return_value=False)
assert datastore.add_watch(url=f"{test_url}?second=1") is None
assert len(datastore.data['watching']) == 1
delete_all_watches(client)
@@ -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
@@ -79,5 +79,69 @@ class TestTriggerConditions(unittest.TestCase):
self.assertTrue(result.get('result'))
def test_conditions_filter_complete_rules_with_zero_values(self):
from changedetectionio.conditions import filter_complete_rules
rules = [
{"operator": "==", "field": "word_count", "value": 0},
{"operator": "<=", "field": "levenshtein_distance", "value": 0.0},
{"operator": "==", "field": "price", "value": "0"},
{"operator": "==", "field": "empty_val", "value": ""},
{"operator": "==", "field": "none_val", "value": None},
{"operator": "==", "field": "str_none", "value": "None"},
{"operator": "", "field": "missing_op", "value": 0},
{"operator": "None", "field": "str_none_op", "value": 0},
{"operator": "==", "field": "", "value": 0},
]
complete = filter_complete_rules(rules)
self.assertEqual(len(complete), 3)
self.assertEqual(complete[0]["value"], 0)
self.assertEqual(complete[1]["value"], 0.0)
self.assertEqual(complete[2]["value"], "0")
def test_conditions_convert_to_jsonlogic_with_zero_values(self):
from json_logic import jsonLogic
from changedetectionio.conditions import convert_to_jsonlogic
rule_int_zero = [{"operator": "==", "field": "word_count", "value": 0}]
jl_int = convert_to_jsonlogic("and", rule_int_zero)
self.assertEqual(jl_int, {"==": [{"var": "word_count"}, 0]})
self.assertTrue(jsonLogic(jl_int, {"word_count": 0}))
self.assertFalse(jsonLogic(jl_int, {"word_count": 5}))
rule_float_zero = [{"operator": "<=", "field": "levenshtein_distance", "value": 0.0}]
jl_float = convert_to_jsonlogic("and", rule_float_zero)
self.assertEqual(jl_float, {"<=": [{"var": "levenshtein_distance"}, 0.0]})
self.assertTrue(jsonLogic(jl_float, {"levenshtein_distance": 0.0}))
self.assertFalse(jsonLogic(jl_float, {"levenshtein_distance": 2.5}))
def test_conditions_execution_zero_word_count(self):
# Test condition checking for empty page (word_count == 0)
self.store.data['watching'][self.watch_uuid].update(
{
"conditions_match_logic": "ALL",
"conditions": [
{"operator": "==", "field": "word_count", "value": 0},
],
}
)
# Empty text has word_count 0 -> condition should match (True)
res_empty = execute_ruleset_against_all_plugins(
current_watch_uuid=self.watch_uuid,
application_datastruct=self.store.data,
ephemeral_data={'text': ""},
)
self.assertTrue(res_empty.get('result'))
# Non-empty text has word_count > 0 -> condition should not match (False)
res_nonempty = execute_ruleset_against_all_plugins(
current_watch_uuid=self.watch_uuid,
application_datastruct=self.store.data,
ephemeral_data={'text': "Hello world"},
)
self.assertFalse(res_nonempty.get('result'))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Tests for the shared "may the server fetch this URL?" gate.
# run from dir above changedetectionio/ dir
# python3 -m unittest changedetectionio.tests.unit.test_fetch_url_gate
Every server-side fetch entry point routes through validate_url.is_fetch_url_allowed(). Before it
existed, the file:// and private-IP rules were enforced inline in call_browser() only, so any fetch
path that did not go through call_browser() was unprotected:
* a "Goto URL" browser step could read file:///etc/passwd (GHSA-hm22-wg2m-35v4)
* /add-watch-ui/snapshot url= could fetch internal hosts (GHSA-56fq-63vj-9992)
These tests pin the gate's rules AND the browser-step choke point, so a future fetch path that
forgets to call the gate is the only way to regress it.
"""
import asyncio
import unittest
from unittest.mock import patch
from changedetectionio.browser_steps.browser_steps import steppable_browser_interface
from changedetectionio.validate_url import (
is_fetch_url_allowed,
is_special_purpose_ip,
validate_fetch_url,
validate_fetch_url_async,
)
# tests/conftest.py sets ALLOW_IANA_RESTRICTED_ADDRESSES=true for the functional suite, so the
# locked-down default has to be re-asserted explicitly rather than assumed.
LOCKED_DOWN = {'ALLOW_IANA_RESTRICTED_ADDRESSES': 'false', 'ALLOW_FILE_URI': 'false'}
OPTED_IN = {'ALLOW_IANA_RESTRICTED_ADDRESSES': 'true', 'ALLOW_FILE_URI': 'true'}
class TestFetchUrlGate(unittest.TestCase):
def assertBlocked(self, url):
ok, reason = is_fetch_url_allowed(url)
self.assertFalse(ok, f"URL '{url}' should have been blocked")
self.assertTrue(reason, f"URL '{url}' was blocked without a reason to show the user")
def assertAllowed(self, url):
ok, reason = is_fetch_url_allowed(url)
self.assertTrue(ok, f"URL '{url}' should have been allowed, got: {reason}")
def test_file_uri_blocked_by_default(self):
with patch.dict('os.environ', LOCKED_DOWN):
# All the spellings that reach the same local file
for url in ('file:///etc/passwd', 'FILE:///etc/passwd', 'file:/etc/passwd', 'file://etc/passwd'):
with self.subTest(url=url):
self.assertBlocked(url)
def test_file_uri_allowed_when_operator_opts_in(self):
with patch.dict('os.environ', OPTED_IN):
self.assertAllowed('file:///etc/passwd')
def test_file_uri_blocked_even_if_safe_protocol_regex_was_loosened(self):
"""An operator who widens SAFE_PROTOCOL_REGEX for some other scheme must not get local
file reads thrown in for free - hence the explicit file: check ahead of is_safe_valid_url()."""
env = dict(LOCKED_DOWN, SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):')
with patch.dict('os.environ', env):
self.assertBlocked('file:///etc/passwd')
def test_private_and_reserved_addresses_blocked_by_default(self):
with patch.dict('os.environ', LOCKED_DOWN):
for url in ('http://127.0.0.1:5000/',
'http://localhost/',
'http://169.254.169.254/latest/meta-data/', # cloud metadata
'http://192.168.1.1/',
'http://10.0.0.1/',
'http://[::1]/'):
with self.subTest(url=url):
self.assertBlocked(url)
def test_cgnat_and_other_non_global_addresses_blocked_by_default(self):
"""GHSA-gwph-fp79-379w - the 0.54.1 predicate only tested is_private/is_loopback/
is_link_local/is_reserved, none of which are True for RFC 6598 CGNAT space, so
100.64.0.0/10 (an ISP's other subscribers, CPE admin panels, CGNAT gateways) stayed
fetchable. These are IP literals, so no DNS is involved and CI cannot flake."""
with patch.dict('os.environ', LOCKED_DOWN):
for url in ('http://100.64.0.1/', # RFC 6598 CGNAT, first usable
'http://100.127.255.254/', # RFC 6598 CGNAT, last usable
'http://100.100.100.100/', # inside CGNAT (Alibaba Cloud metadata)
'http://192.88.99.1/', # RFC 7526 deprecated 6to4 relay anycast
'http://224.0.0.1/', # IPv4 multicast all-hosts
'http://[ff02::1]/'): # IPv6 multicast all-nodes
with self.subTest(url=url):
self.assertBlocked(url)
def test_cgnat_allowed_when_operator_opts_in(self):
"""CGNAT is legitimate for operators monitoring their own carrier network, so the
opt-in has to release it the same way it releases 127.0.0.1."""
with patch.dict('os.environ', OPTED_IN):
self.assertAllowed('http://100.64.0.1/')
def test_special_purpose_ip_classification(self):
"""The predicate itself, without DNS - one place to pin what is and is not fetchable."""
for ip in ('100.64.0.1', '100.127.255.254', '192.88.99.1', '224.0.0.1', 'ff02::1',
'127.0.0.1', '10.0.0.1', '169.254.169.254', '192.168.1.1', '::1',
'0.0.0.0', '255.255.255.255', '198.18.0.1', 'fc00::1', 'fe80::1',
'::ffff:100.64.0.1', # CGNAT wrapped as an IPv4-mapped IPv6 address
'2002:6440:1::'): # CGNAT wrapped as a 6to4 address
with self.subTest(ip=ip):
blocked, why = is_special_purpose_ip(ip)
self.assertTrue(blocked, f"{ip} should be refused")
self.assertTrue(why, f"{ip} was refused without a stated reason")
for ip in ('1.1.1.1', '8.8.8.8', '93.184.216.34', '2606:4700:4700::1111'):
with self.subTest(ip=ip):
blocked, why = is_special_purpose_ip(ip)
self.assertFalse(blocked, f"public address {ip} was refused as '{why}'")
def test_cgnat_boundaries_are_exact(self):
"""100.64.0.0/10 ends at 100.127.255.255 - 100.63.x and 100.128.x are ordinary public
space and must not be collateral damage from a /8-sized over-block."""
for ip in ('100.63.255.255', '100.128.0.0'):
with self.subTest(ip=ip):
blocked, why = is_special_purpose_ip(ip)
self.assertFalse(blocked, f"public address {ip} was refused as '{why}'")
def test_private_addresses_allowed_when_operator_opts_in(self):
with patch.dict('os.environ', OPTED_IN):
self.assertAllowed('http://127.0.0.1:5000/')
def test_source_prefix_is_stripped_before_the_hostname_check(self):
"""Load-bearing, not cosmetic: urlparse('source:http://127.0.0.1/') reports NO hostname,
so leaving the prefix on would hand the private-IP check nothing to look at and let it pass."""
with patch.dict('os.environ', LOCKED_DOWN):
self.assertBlocked('source:http://127.0.0.1/')
self.assertBlocked('SOURCE:http://169.254.169.254/')
self.assertBlocked('source:file:///etc/passwd')
def test_jinja2_is_rendered_before_the_hostname_check(self):
"""The fetch uses the rendered URL, so the rendered URL is what must be judged - otherwise
a template expression hides the real target from the check."""
with patch.dict('os.environ', LOCKED_DOWN):
self.assertBlocked("http://{{ '127.0.0.1' }}/")
self.assertBlocked("http://{% if 1 %}127.0.0.1{% endif %}/")
def test_parser_differential_payload_always_rejected(self):
"""GHSA-rph4-96w6-q594: urlparse sees PUBLIC, urllib3 connects to INTERNAL. A backslash has
no legitimate use in a URL, so this is refused even with both opt-ins enabled."""
for env in (LOCKED_DOWN, OPTED_IN):
with self.subTest(env=env), patch.dict('os.environ', env):
self.assertBlocked('http://127.0.0.1:8888\\@example.com/')
def test_unsupported_schemes_rejected(self):
with patch.dict('os.environ', LOCKED_DOWN):
for url in ('javascript:alert(1)', 'data:text/html,<h1>x', 'chrome://version'):
with self.subTest(url=url):
self.assertBlocked(url)
def test_empty_input_rejected(self):
with patch.dict('os.environ', LOCKED_DOWN):
for url in ('', ' ', None):
with self.subTest(url=url):
self.assertBlocked(url)
def test_ordinary_public_urls_still_allowed(self):
# Unresolvable hostnames are allowed by design (DNS may be down, domain not yet live), so
# these pass with or without working DNS in CI.
with patch.dict('os.environ', LOCKED_DOWN):
for url in ('https://example.com/',
'source:https://example.com/',
'https://example.com/path?a=b&c=d#frag'):
with self.subTest(url=url):
self.assertAllowed(url)
def test_validate_fetch_url_raises_with_the_reason(self):
with patch.dict('os.environ', LOCKED_DOWN):
with self.assertRaises(ValueError):
validate_fetch_url('file:///etc/passwd')
validate_fetch_url('https://example.com/') # must not raise
def test_validate_fetch_url_async_raises_with_the_reason(self):
with patch.dict('os.environ', LOCKED_DOWN):
with self.assertRaises(ValueError):
asyncio.run(validate_fetch_url_async('http://127.0.0.1/'))
asyncio.run(validate_fetch_url_async('https://example.com/')) # must not raise
class _RecordingPage:
"""Stands in for the Playwright page so we can assert navigation never happened."""
def __init__(self):
self.goto_calls = []
async def goto(self, url, **kwargs):
self.goto_calls.append(url)
return None
async def wait_for_timeout(self, ms):
return None
class TestBrowserStepGotoUrlGate(unittest.TestCase):
"""GHSA-hm22-wg2m-35v4 - browser step values are raw user input and were never validated.
action_goto_url() is the single choke point for every navigation we initiate (the "Goto URL"
step, "Goto site", the live Browser Steps UI and the Add Watch preview all land here), so the
assertion that matters is that page.goto() is never reached for a refused URL.
"""
def _interface(self, start_url='https://example.com/'):
interface = steppable_browser_interface(start_url=start_url)
interface.page = _RecordingPage()
return interface
def test_goto_url_step_cannot_read_local_files(self):
with patch.dict('os.environ', LOCKED_DOWN):
interface = self._interface()
with self.assertRaises(ValueError):
asyncio.run(interface.action_goto_url(value='file:///etc/passwd'))
self.assertEqual(interface.page.goto_calls, [], "Chromium was navigated to a refused URL")
def test_goto_url_step_cannot_reach_private_addresses(self):
with patch.dict('os.environ', LOCKED_DOWN):
for url in ('http://127.0.0.1:5000/', 'http://169.254.169.254/latest/meta-data/'):
with self.subTest(url=url):
interface = self._interface()
with self.assertRaises(ValueError):
asyncio.run(interface.action_goto_url(value=url))
self.assertEqual(interface.page.goto_calls, [])
def test_goto_site_step_validates_the_start_url_too(self):
with patch.dict('os.environ', LOCKED_DOWN):
interface = self._interface(start_url='source:http://127.0.0.1/')
with self.assertRaises(ValueError):
asyncio.run(interface.action_goto_site())
self.assertEqual(interface.page.goto_calls, [])
def test_permitted_url_still_navigates(self):
with patch.dict('os.environ', LOCKED_DOWN):
interface = self._interface()
asyncio.run(interface.action_goto_url(value='https://example.com/'))
self.assertEqual(interface.page.goto_calls, ['https://example.com/'])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,117 @@
import unittest
from unittest.mock import MagicMock, patch
from changedetectionio.llm import client as m
from changedetectionio.llm import evaluator as ev
class TestGeminiFlashLiteClientHandling(unittest.TestCase):
def _mock_success_response(
self, text="test response", total_tokens=50, input_tokens=30, output_tokens=20
):
mock_resp = MagicMock()
mock_resp.choices = [
MagicMock(message=MagicMock(content=text, parts=None), finish_reason="stop")
]
mock_usage = MagicMock()
mock_usage.total_tokens = total_tokens
mock_usage.prompt_tokens = input_tokens
mock_usage.completion_tokens = output_tokens
mock_resp.usage = mock_usage
return mock_resp
def _bad_request(self, message="Request contains an invalid argument."):
import litellm
return litellm.BadRequestError(
message=message,
model="gemini/gemini-2.0-flash-lite",
llm_provider="gemini",
response=MagicMock(status_code=400),
)
def test_flash_lite_model_omits_temperature_initially(self):
mock_resp = self._mock_success_response()
with patch("litellm.completion", return_value=mock_resp) as mock_call:
text, total_tok, in_tok, out_tok = m.completion(
model="gemini/gemini-2.0-flash-lite",
messages=[{"role": "user", "content": "hi"}],
)
self.assertEqual(text, "test response")
self.assertEqual(total_tok, 50)
self.assertEqual(in_tok, 30)
self.assertEqual(out_tok, 20)
kwargs = mock_call.call_args.kwargs
self.assertNotIn("temperature", kwargs)
def test_standard_model_includes_temperature_zero(self):
mock_resp = self._mock_success_response()
with patch("litellm.completion", return_value=mock_resp) as mock_call:
m.completion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hi"}],
)
kwargs = mock_call.call_args.kwargs
self.assertEqual(kwargs.get("temperature"), 0)
def test_bad_request_strips_temperature_and_extra_body_and_retries(self):
exc = self._bad_request()
mock_resp = self._mock_success_response()
with patch("litellm.completion", side_effect=[exc, mock_resp]) as mock_call:
text, total_tok, in_tok, out_tok = m.completion(
model="gemini/gemini-2.5-flash",
messages=[{"role": "user", "content": "hi"}],
extra_body={"generationConfig": {"thinkingConfig": {"thinkingBudget": 0}}},
)
self.assertEqual(text, "test response")
self.assertEqual(mock_call.call_count, 2)
first_call_kwargs = mock_call.call_args_list[0].kwargs
second_call_kwargs = mock_call.call_args_list[1].kwargs
self.assertIn("temperature", first_call_kwargs)
self.assertIn("extra_body", first_call_kwargs)
self.assertNotIn("temperature", second_call_kwargs)
self.assertNotIn("extra_body", second_call_kwargs)
def test_does_not_infinite_loop_when_nothing_left_to_strip(self):
exc = self._bad_request("another 400 error")
with patch("litellm.completion", side_effect=exc) as mock_call:
with self.assertRaises(type(exc)):
m.completion(
model="gemini/gemini-2.0-flash-lite",
messages=[{"role": "user", "content": "hi"}],
)
self.assertEqual(mock_call.call_count, 1)
def test_only_retries_once_on_persistent_400(self):
exc1 = self._bad_request("first 400")
exc2 = self._bad_request("second 400")
with patch("litellm.completion", side_effect=[exc1, exc2]) as mock_call:
with self.assertRaises(type(exc2)):
m.completion(
model="gemini/gemini-2.5-flash",
messages=[{"role": "user", "content": "hi"}],
extra_body={"generationConfig": {"thinkingConfig": {"thinkingBudget": 0}}},
)
self.assertEqual(mock_call.call_count, 2)
class TestThinkingExtraBodyFlashLite(unittest.TestCase):
def test_flash_lite_model_gets_no_thinking_config(self):
self.assertIsNone(ev._thinking_extra_body("gemini/gemini-2.0-flash-lite", budget=0))
self.assertIsNone(ev._thinking_extra_body("gemini/gemini-3.5-flash-lite", budget=0))
def test_non_gemini_model_gets_no_thinking_config(self):
self.assertIsNone(ev._thinking_extra_body("gpt-4o-mini", budget=100))
@patch("litellm.get_model_info")
def test_gemini_supporting_reasoning_gets_thinking_config(self, mock_info):
mock_info.return_value = {"supports_reasoning": True}
result = ev._thinking_extra_body("gemini/gemini-2.5-flash", budget=512)
self.assertEqual(result, {"generationConfig": {"thinkingConfig": {"thinkingBudget": 512}}})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
Regression tests for the scheduler's default-timezone resolution.
The bug: model/App.py initialises 'scheduler_timezone_default' to None, so the
key ALWAYS EXISTS with a None value. Call sites used
settings['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC'))
which never reaches its fallback, because dict.get() only substitutes the
default when the key is ABSENT - not when its value is None. The resulting None
flowed into is_within_schedule(), which does `tz_name.strip()` and raised
AttributeError. That took out the edit page (HTTP 500) and, worse, the ticker
thread (which did `return False` inside its main loop and stopped scheduling
every watch until restart).
Run: python3 -m unittest changedetectionio.tests.unit.test_scheduler_timezone_resolution
"""
import os
import unittest
from unittest.mock import patch
from changedetectionio.time_handler import default_timezone_name, is_within_schedule
class TestDefaultTimezoneName(unittest.TestCase):
def test_none_falls_back_to_utc(self):
"""The exact bug: a present-but-None setting must not stay None."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop('TZ', None)
self.assertEqual(default_timezone_name(None), 'UTC')
def test_empty_string_falls_back(self):
with patch.dict(os.environ, {}, clear=False):
os.environ.pop('TZ', None)
self.assertEqual(default_timezone_name(''), 'UTC')
self.assertEqual(default_timezone_name(' '), 'UTC')
def test_configured_value_wins(self):
self.assertEqual(default_timezone_name('Pacific/Kiritimati'), 'Pacific/Kiritimati')
def test_configured_value_is_stripped(self):
self.assertEqual(default_timezone_name(' Europe/Berlin '), 'Europe/Berlin')
def test_tz_env_used_when_unconfigured(self):
with patch.dict(os.environ, {'TZ': 'Asia/Tokyo'}):
self.assertEqual(default_timezone_name(None), 'Asia/Tokyo')
def test_configured_beats_tz_env(self):
with patch.dict(os.environ, {'TZ': 'Asia/Tokyo'}):
self.assertEqual(default_timezone_name('Europe/Berlin'), 'Europe/Berlin')
def test_never_returns_falsy(self):
with patch.dict(os.environ, {'TZ': ' '}):
self.assertEqual(default_timezone_name(None), 'UTC')
for value in (None, '', ' ', 0, False, []):
self.assertTrue(default_timezone_name(value),
f"default_timezone_name({value!r}) must never be falsy")
def test_reproduces_the_original_crash(self):
"""Passing the un-resolved None straight through still raises - proving
the resolver is what prevents it, not luck elsewhere."""
schedule = {
'enabled': True,
'monday': {'enabled': True, 'start_time': '00:00',
'duration': {'hours': '24', 'minutes': '00'}},
}
with self.assertRaises(AttributeError):
is_within_schedule(time_schedule_limit=schedule, default_tz=None)
# ...and does not raise once resolved
full = {'enabled': True}
for day in ('monday', 'tuesday', 'wednesday', 'thursday',
'friday', 'saturday', 'sunday'):
full[day] = {'enabled': True, 'start_time': '00:00',
'duration': {'hours': '24', 'minutes': '00'}}
self.assertTrue(
is_within_schedule(time_schedule_limit=full,
default_tz=default_timezone_name(None))
)
class TestApiTimezoneValidation(unittest.TestCase):
"""
The edit form runs validateTimeZoneName on time_schedule_limit.timezone, but
the API did not: the OpenAPI schema declared no `timezone` property and did
not set additionalProperties:false, so any string was accepted and stored.
"""
DAYS = ('monday', 'tuesday', 'wednesday', 'thursday',
'friday', 'saturday', 'sunday')
def _payload(self, timezone=..., ):
sched = {'enabled': True}
for day in self.DAYS:
sched[day] = {'enabled': True, 'start_time': '00:00',
'duration': {'hours': '24', 'minutes': '00'}}
if timezone is not ...:
sched['timezone'] = timezone
return {'time_schedule_limit': sched}
def setUp(self):
from changedetectionio.api.Watch import validate_time_schedule_limit
self.validate = validate_time_schedule_limit
def test_valid_zones_accepted(self):
for tz in ('UTC', 'Europe/Berlin', 'America/Los_Angeles', 'Pacific/Kiritimati'):
self.assertIsNone(self.validate(self._payload(tz)), f"{tz} should be valid")
def test_absent_or_empty_timezone_is_fine(self):
self.assertIsNone(self.validate(self._payload())) # key absent
self.assertIsNone(self.validate(self._payload(''))) # empty
self.assertIsNone(self.validate(self._payload(None))) # explicit null
def test_unknown_zone_rejected(self):
err = self.validate(self._payload('Not/ARealZone'))
self.assertIsNotNone(err)
self.assertIn('Not/ARealZone', err)
self.assertIn('not a valid timezone', err.lower())
def test_case_sensitive_like_the_form(self):
"""'utc' is not an IANA name; the form rejects it, so must the API."""
self.assertIsNotNone(self.validate(self._payload('utc')))
def test_non_string_rejected(self):
for bad in (123, 12.5, True, ['UTC'], {'name': 'UTC'}):
self.assertIsNotNone(self.validate(self._payload(bad)),
f"{bad!r} should be rejected")
def test_no_schedule_key_at_all(self):
self.assertIsNone(self.validate({}))
self.assertIsNone(self.validate({'time_schedule_limit': None}))
self.assertIsNone(self.validate({'url': 'https://example.com'}))
def test_non_dict_schedule_is_left_to_openapi(self):
"""Structural type errors are the OpenAPI layer's job, not ours."""
self.assertIsNone(self.validate({'time_schedule_limit': 'nope'}))
self.assertIsNone(self.validate({'time_schedule_limit': []}))
def test_rejected_zone_would_have_crashed_the_scheduler(self):
"""Tie the validation back to the actual failure it prevents."""
payload = self._payload('Not/ARealZone')
self.assertIsNotNone(self.validate(payload))
with self.assertRaises(Exception):
is_within_schedule(time_schedule_limit=payload['time_schedule_limit'],
default_tz='UTC')
class TestApiSpecDocumentsTimezone(unittest.TestCase):
"""The field is real and settable; the published contract must say so."""
def test_timezone_is_declared_in_the_openapi_spec(self):
import os
import yaml
here = os.path.dirname(__file__)
spec_path = os.path.join(here, '..', '..', '..', 'docs', 'api-spec.yaml')
if not os.path.isfile(spec_path):
self.skipTest("api-spec.yaml not present in this layout")
with open(spec_path) as f:
spec = yaml.safe_load(f)
schedule = spec['components']['schemas']['WatchBase']['properties']['time_schedule_limit']
self.assertIn('timezone', schedule['properties'],
"time_schedule_limit.timezone is settable via the form and the "
"API but is missing from the OpenAPI spec")
self.assertEqual(schedule['properties']['timezone']['type'], 'string')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
Tests for thread_supervisor.supervise().
Context: the ticker thread had `return False` buried inside its per-watch loop.
One watch with an unresolvable timezone made the whole loop return, the thread
ended, and no watch was ever checked again - with the process still up and
looking healthy. threading.Thread cannot be restarted (start() twice raises
RuntimeError), so recovery has to live inside the thread.
The two behaviours that matter:
- a target that returns or raises is restarted, loudly
- a target that exits during shutdown is NOT restarted
Run: python3 -m unittest changedetectionio.tests.unit.test_thread_supervisor
"""
import threading
import time
import unittest
from changedetectionio.thread_supervisor import supervise, start_supervised_thread
class TestRestartsOnDeath(unittest.TestCase):
def test_target_that_returns_is_restarted(self):
"""The exact ticker bug: a stray `return` must not end the thread."""
exit_event = threading.Event()
calls = []
def flaky():
calls.append('run')
if len(calls) >= 3:
exit_event.set() # let the supervisor finish
return # simulates `return False` in the loop
supervise(flaky, 'flaky', exit_event, max_backoff=0.01, healthy_after=9999)
self.assertEqual(len(calls), 3, "target should have been restarted twice")
def test_target_that_raises_is_restarted(self):
exit_event = threading.Event()
calls = []
def crashy():
calls.append('run')
if len(calls) >= 3:
exit_event.set()
return
raise RuntimeError("boom")
supervise(crashy, 'crashy', exit_event, max_backoff=0.01, healthy_after=9999)
self.assertEqual(len(calls), 3)
def test_restart_reason_is_reported(self):
exit_event = threading.Event()
reasons = []
calls = []
def mixed():
calls.append('run')
if len(calls) == 1:
return # clean but wrong
if len(calls) == 2:
raise ValueError("nope") # crash
exit_event.set()
supervise(mixed, 'mixed', exit_event, max_backoff=0.01, healthy_after=9999,
_on_restart=lambda n, reason: reasons.append(reason))
self.assertEqual(len(reasons), 2)
self.assertIn('returned unexpectedly', reasons[0])
self.assertIn('ValueError: nope', reasons[1])
def test_backoff_grows_then_resets_after_healthy_run(self):
exit_event = threading.Event()
stamps = []
def failing():
stamps.append(time.monotonic())
if len(stamps) >= 4:
exit_event.set()
return
raise RuntimeError("boom")
supervise(failing, 'failing', exit_event, max_backoff=0.08, healthy_after=9999)
gaps = [stamps[i + 1] - stamps[i] for i in range(len(stamps) - 1)]
# 1s doubling is capped at max_backoff, so every gap should hit the cap
for gap in gaps:
self.assertGreaterEqual(gap, 0.05, f"gaps={gaps} - backoff not applied")
def test_does_not_spin_hot(self):
"""A target failing instantly must not be restarted in a tight loop."""
exit_event = threading.Event()
calls = []
start = time.monotonic()
def instant():
calls.append(1)
if len(calls) >= 3:
exit_event.set()
return
raise RuntimeError("instant")
supervise(instant, 'instant', exit_event, max_backoff=0.05, healthy_after=9999)
self.assertGreater(time.monotonic() - start, 0.05,
"supervisor restarted with no delay - would burn CPU")
class TestDoesNotRestartDuringShutdown(unittest.TestCase):
def test_exit_event_stops_restarting(self):
exit_event = threading.Event()
calls = []
def target():
calls.append('run')
exit_event.set() # shutdown requested, then return normally
supervise(target, 'target', exit_event, max_backoff=0.01)
self.assertEqual(len(calls), 1, "must not restart once exit_event is set")
def test_exit_event_set_before_start_never_runs_target(self):
exit_event = threading.Event()
exit_event.set()
calls = []
supervise(lambda: calls.append('run'), 'target', exit_event)
self.assertEqual(calls, [])
def test_secondary_shutdown_flag_stops_restarting(self):
"""sigshutdown_handler also sets datastore.stop_thread - honour it."""
exit_event = threading.Event()
state = {'stop_thread': False}
calls = []
def target():
calls.append('run')
state['stop_thread'] = True # only the secondary flag is set
return
supervise(target, 'target', exit_event,
is_shutting_down=lambda: state['stop_thread'],
max_backoff=0.01)
self.assertEqual(len(calls), 1,
"must not restart when the secondary shutdown flag is set")
self.assertFalse(exit_event.is_set(), "primary flag was never set in this test")
def test_raising_shutdown_predicate_does_not_block_restart(self):
"""A broken predicate must not silently disable recovery."""
exit_event = threading.Event()
calls = []
def target():
calls.append('run')
if len(calls) >= 2:
exit_event.set()
return
def broken():
raise RuntimeError("predicate is broken")
supervise(target, 'target', exit_event, is_shutting_down=broken,
max_backoff=0.01, healthy_after=9999)
self.assertEqual(len(calls), 2, "should still have restarted once")
class TestStartSupervisedThread(unittest.TestCase):
def test_returns_a_real_thread_object(self):
"""Thread(...).start() returns None - that is why ticker_thread was
always None and nothing could check is_alive()."""
exit_event = threading.Event()
started = threading.Event()
def target():
started.set()
exit_event.wait(5)
t = start_supervised_thread(target, 'TestThread', exit_event)
self.addCleanup(exit_event.set)
self.assertIsInstance(t, threading.Thread)
self.assertTrue(started.wait(5), "target did not run")
self.assertTrue(t.is_alive())
self.assertTrue(t.daemon)
self.assertEqual(t.name, 'TestThread')
exit_event.set()
t.join(timeout=5)
self.assertFalse(t.is_alive(), "thread did not exit on shutdown")
def test_thread_survives_a_dying_target(self):
"""End to end: the thread outlives a target that keeps returning."""
exit_event = threading.Event()
calls = []
done = threading.Event()
def target():
calls.append(1)
if len(calls) >= 3:
done.set()
exit_event.wait(5)
return
t = start_supervised_thread(target, 'Dying', exit_event, max_backoff=0.01,
healthy_after=9999)
self.addCleanup(exit_event.set)
self.assertTrue(done.wait(10), f"target was not restarted (calls={len(calls)})")
self.assertTrue(t.is_alive(), "thread died despite supervision")
exit_event.set()
t.join(timeout=5)
if __name__ == '__main__':
unittest.main()
@@ -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' %}"
@@ -205,8 +205,8 @@ def test_browsersteps_edit_UI_startsession(client, live_server, measure_memory_u
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'fetch_backend': 'html_webdriver', 'paused': True})
# Test starting a browsersteps session
res = client.get(
# Test starting a browsersteps session (POST only - it spins up a real browser)
res = client.post(
url_for("browser_steps.browsersteps_start_session", uuid=uuid),
follow_redirects=True
)
+132
View File
@@ -0,0 +1,132 @@
"""
Keep critical background threads alive.
A threading.Thread cannot be restarted. Once its target returns, the thread is
finished and calling start() again raises RuntimeError("threads can only be
started once"). There is no built-in "respawn on death" option. So recovery has
to be built into the target itself.
This matters because a background loop can die in two ways that look identical
from outside, and both are silent:
- it raises, and the traceback goes nowhere because nobody joins a daemon thread
- it simply `return`s, e.g. a stray `return False` deep inside the loop body
The second is how the ticker thread stopped scheduling every watch in the app:
one watch with an unresolvable timezone hit `return False`, the whole loop
exited, and no watch was ever checked again until the process restarted. Nothing
was logged above ERROR, and the process stayed up and healthy-looking.
supervise() wraps a target so that any exit which is NOT a real shutdown is
logged CRITICAL and the target is re-entered, with exponential backoff so a
target that fails instantly cannot spin the CPU.
"""
import time
from loguru import logger
# Back off 1, 2, 4 ... up to this many seconds between restarts.
DEFAULT_MAX_BACKOFF_SECONDS = 60
# A target that stayed up at least this long is considered to have recovered,
# so its backoff resets rather than continuing to grow.
HEALTHY_RUNTIME_SECONDS = 60
def supervise(target, name, exit_event, is_shutting_down=None,
max_backoff=DEFAULT_MAX_BACKOFF_SECONDS,
healthy_after=HEALTHY_RUNTIME_SECONDS,
_on_restart=None):
"""
Run `target` forever, restarting it if it ever returns or raises.
Never restarts once shutdown has begun - an exiting target is expected then,
and respawning it would fight the shutdown path and delay process exit.
Args:
target: zero-arg callable expected to loop until shutdown
name: thread name, used in log messages
exit_event: threading.Event - the primary shutdown signal
is_shutting_down: optional extra zero-arg predicate returning True during
shutdown. sigshutdown_handler() sets both app.config.exit
and datastore.stop_thread, so pass the latter here to be
certain a restart can never race a shutdown.
max_backoff: ceiling for the restart delay, in seconds
healthy_after: runtime after which the backoff resets to 1s
_on_restart: test hook, called with (restart_count, reason) before each retry
Returns:
None - only once shutdown is signalled
"""
def stopping():
if exit_event.is_set():
return True
if is_shutting_down is not None:
try:
return bool(is_shutting_down())
except Exception:
# A broken predicate must not keep a healthy thread from restarting
return False
return False
backoff = 1
restarts = 0
while not stopping():
started = time.monotonic()
reason = None
try:
target()
# Falling out of the target is only legitimate during shutdown.
if stopping():
break
reason = "returned unexpectedly (a stray `return` inside its loop?)"
logger.critical(
f"{name} {reason} - this thread must run until shutdown. Restarting it."
)
except Exception as e:
if stopping():
break
reason = f"crashed: {type(e).__name__}: {e}"
logger.opt(exception=True).critical(f"{name} {reason} - restarting it.")
ran_for = time.monotonic() - started
backoff = 1 if ran_for >= healthy_after else min(backoff * 2, max_backoff)
restarts += 1
if _on_restart:
_on_restart(restarts, reason)
logger.critical(
f"{name} restart #{restarts} in {backoff}s (previous run lasted {ran_for:.1f}s)"
)
# wait() returns True immediately once shutdown is requested
if exit_event.wait(backoff) or stopping():
break
logger.info(f"{name} supervisor exiting - shutdown requested "
f"(after {restarts} restart(s))")
def start_supervised_thread(target, name, exit_event, is_shutting_down=None, **kwargs):
"""
Start `target` in a daemon thread wrapped in supervise().
Returns:
threading.Thread: the started thread (unlike Thread(...).start(), which
returns None - assigning that to a module global is why nothing could
ever check the ticker thread's is_alive()).
"""
import threading
thread = threading.Thread(
target=supervise,
args=(target, name, exit_event),
kwargs={'is_shutting_down': is_shutting_down, **kwargs},
daemon=True,
name=name,
)
thread.start()
return thread
+25 -1
View File
@@ -1,9 +1,31 @@
import os
from functools import lru_cache
import arrow
from enum import IntEnum
def default_timezone_name(configured=None):
"""
Resolve the scheduler's default IANA timezone name. Never returns None/''.
`scheduler_timezone_default` is initialised to None in model/App.py, so the
key always EXISTS with a None value. That makes the common idiom
settings['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC'))
silently return None - dict.get() only falls back when the key is absent,
not when its value is None. The None then reaches is_within_schedule(),
which does `tz_name.strip()` and raises AttributeError.
Args:
configured: the stored setting value (may be None or empty)
Returns:
str: a non-empty timezone name, falling back to $TZ then 'UTC'
"""
return (configured or os.getenv('TZ') or 'UTC').strip() or 'UTC'
class Weekday(IntEnum):
"""Enumeration for days of the week."""
Monday = 0
@@ -32,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()]
@@ -91,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')

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