Compare commits

..
53 changed files with 179 additions and 1150 deletions
-35
View File
@@ -1,25 +1,12 @@
# `pre-commit install` installs both hook types, so contributors get the
# pre-push checks too without needing to remember `-t pre-push`.
default_install_hook_types: [pre-commit, pre-push]
repos:
# `manual` stage only: these never run on commit or push. The full ruleset in
# .ruff.toml is aspirational (~600 violations are not auto-fixable and
# ruff-format would rewrite most of the tree), so enforcing it on commit would
# permanently block edits to files like changedetectionio/__init__.py.
# CI matches this stance: it gates on E9,F63,F7,F82,INT and runs the rest with
# --exit-zero. Run on demand with:
# pre-commit run --hook-stage manual --all-files
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.2
hooks:
# Lint (and apply safe fixes)
- id: ruff
args: [--fix]
stages: [manual]
# Fomrat
- id: ruff-format
stages: [manual]
- repo: local
hooks:
@@ -28,7 +15,6 @@ repos:
language: system
entry: dennis-cmd lint --strict
files: ^changedetectionio/translations/messages\.pot$
stages: [pre-commit]
pass_filenames: true
- id: dennis-lint-po
@@ -36,25 +22,4 @@ repos:
language: system
entry: dennis-cmd lint --strict --excluderules=W302
files: ^changedetectionio/translations/\w+/LC_MESSAGES/messages\.po$
stages: [pre-commit]
pass_filenames: true
# Runs at commit time so a release commit that bumps __version__ without
# re-running extract_messages fails *before* the release tag is created.
# Scoped to the two files that can drift, so unrelated commits aren't blocked.
- id: translations-version-sync
name: translations catalog version matches app version
language: system
entry: python scripts/check_translations_version.py
files: ^changedetectionio/(__init__\.py|translations/messages\.pot)$
stages: [pre-commit]
pass_filenames: false
# Backstop for commits made with --no-verify, and for tag-only pushes.
- id: translations-version-sync-push
name: translations catalog version matches app version (push)
language: system
entry: python scripts/check_translations_version.py
stages: [pre-push]
pass_filenames: false
always_run: true
-4
View File
@@ -6,10 +6,6 @@ Otherwise, it's always best to PR into the `master` branch.
Install the development and test dependencies with `pip install -r requirements-dev.txt`.
Then activate the git hooks once with `pre-commit install` — this wires up linting on commit and a
translation catalog check on push, matching what CI enforces. Git cannot enable hooks automatically
on clone, so this step is manual.
Please be sure that all new functionality has a matching test!
Use `pytest` to validate/test, you can run the existing tests as `pytest tests/test_notification.py` for example
+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.60.2'
__version__ = '0.55.8'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
-9
View File
@@ -194,15 +194,6 @@ 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 = []
+10 -6
View File
@@ -560,12 +560,6 @@ 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
@@ -576,6 +570,16 @@ 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
@@ -17,7 +17,6 @@ class Importer():
self.good = 0
self.remaining_data = []
self.import_profile = None
self.limit_reported = False
@abstractmethod
def run(self,
@@ -26,21 +25,6 @@ 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):
"""
@@ -60,7 +44,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 idx, url in enumerate(urls):
for url in urls:
url = url.strip()
if not len(url):
continue
@@ -75,12 +59,6 @@ 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}
@@ -129,9 +107,6 @@ 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':
@@ -225,9 +200,6 @@ 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'),
@@ -309,9 +281,6 @@ 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,
@@ -261,8 +261,6 @@ 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(),
@@ -391,9 +391,6 @@ 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>
@@ -293,9 +293,6 @@ 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}))
+3 -17
View File
@@ -34,13 +34,7 @@ CUSTOM_OPERATIONS = {
def filter_complete_rules(ruleset):
rules = [
rule for rule in ruleset
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")
)
if all(value not in ("", False, "None", None) for value in [rule["operator"], rule["field"], rule["value"]])
]
return rules
@@ -60,20 +54,12 @@ def convert_to_jsonlogic(logic_operator: str, rule_dict: list):
field = condition["field"]
value = condition["value"]
if (
not operator
or operator == 'None'
or not field
or value is None
or value is False
or value == ""
or value == "None"
):
if not operator or operator == 'None' or not value or not field:
raise EmptyConditionRuleRowNotUsable()
# Convert value to int/float if possible
try:
if isinstance(value, str) and "." in value and value != "None":
if isinstance(value, str) and "." in value and str != "None":
value = float(value)
else:
value = int(value)
+26 -61
View File
@@ -6,7 +6,6 @@ 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).
@@ -25,9 +24,6 @@ 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
@@ -67,16 +63,10 @@ def _install_litellm_debug():
logger.info("LLM client: litellm debug logging routed through loguru")
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]:
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]:
"""
Call the LLM and return (response_text, total_tokens, input_tokens, output_tokens).
Retries up to DEFAULT_RETRIES times on timeout or connection errors.
@@ -89,7 +79,7 @@ def completion( # noqa: C901
try:
import litellm
except ImportError:
raise RuntimeError("litellm is not installed. Add it to requirements.txt.") from None
raise RuntimeError("litellm is not installed. Add it to requirements.txt.")
if debug:
_install_litellm_debug()
@@ -100,12 +90,9 @@ def completion( # noqa: C901
'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:
@@ -135,9 +122,9 @@ def completion( # noqa: C901
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 ''
@@ -146,9 +133,7 @@ def completion( # noqa: C901
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(
@@ -164,13 +149,9 @@ def completion( # noqa: C901
)
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}) "
@@ -200,37 +181,21 @@ def completion( # noqa: C901
raise
except litellm.BadRequestError as e:
# 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:
# 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)):
dropped = [p for p in _sampling_params if kwargs.pop(p, None) is not None]
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
_stripped_sampling = True
attempt -= 1
logger.warning(
f"LLM client: model={model!r} rejected sampling params {dropped} "
f"({e}); retrying without them"
)
continue
logger.warning(f"LLM call failed: model={model!r} error={e}")
raise
-2
View File
@@ -98,8 +98,6 @@ 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'):
+26 -89
View File
@@ -5,8 +5,7 @@ from changedetectionio.strtobool import strtobool
from changedetectionio.validate_url import is_safe_valid_url
from flask import (
flash,
has_request_context
flash
)
from flask_babel import gettext
@@ -49,9 +48,6 @@ 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
@@ -665,8 +661,9 @@ 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])
# None when add_watch() refused it (e.g. PAGE_WATCH_LIMIT), having already flashed why
return self.add_watch(url=url, extras=extras)
new_uuid = self.add_watch(url=url, extras=extras)
watch = self.data['watching'][new_uuid]
return new_uuid
def url_exists(self, url):
@@ -682,38 +679,6 @@ 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
@@ -777,48 +742,32 @@ 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
# 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
# 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")
if tag and type(tag) == str:
# 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:
# 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)
apply_extras['tags'].append(tag_uuid)
# Or if UUIDs given directly
@@ -1092,18 +1041,6 @@ 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()
@@ -1111,10 +1048,10 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
if not n:
return False
existing_uuid = self.tag_uuid_for_title(title)
if existing_uuid:
logger.warning(f"Tag '{title}' already exists, skipping creation.")
return existing_uuid
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
# Eventually almost everything todo with a watch will apply as a Tag
# So we use the same model as a Watch
-77
View File
@@ -321,80 +321,3 @@ 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"
-222
View File
@@ -1,222 +0,0 @@
#!/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)
@@ -79,69 +79,5 @@ 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()
@@ -1,117 +0,0 @@
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()
@@ -861,10 +861,6 @@ msgstr ""
msgid "Python version:"
msgstr "Verze Pythonu:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "Maximální počet sledování stránek:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "Pluginy aktivní:"
@@ -3978,11 +3974,6 @@ msgstr "Změny textu webové stránky/HTML, JSON a PDF"
msgid "Detects all text changes where possible"
msgstr "Detekuje všechny změny textu, kde je to možné"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Dosažen limit sledování ({current}/{limit}). Nelze přidat další."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -3992,6 +3983,11 @@ msgstr ""
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Tělo pro všechna oznámení — Můžete použít"
@@ -877,10 +877,6 @@ msgstr ""
msgid "Python version:"
msgstr "Python-Version:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "Maximale Anzahl an Seitenüberwachungen:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "Aktive Plugins:"
@@ -4030,11 +4026,6 @@ msgstr "Änderungen an Webseitentext/HTML, JSON und PDF"
msgid "Detects all text changes where possible"
msgstr "Erkennt nach Möglichkeit alle Textänderungen"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Überwachungslimit erreicht ({current}/{limit}). Keine weiteren möglich."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -4044,6 +4035,11 @@ msgstr "Fehler beim Abrufen der Metadaten für {}"
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "Das Protokoll wird nicht unterstützt oder das URL-Format ist ungültig."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Inhalt für alle Benachrichtigungen — Sie können verwenden"
@@ -859,10 +859,6 @@ msgstr ""
msgid "Python version:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr ""
@@ -3970,11 +3966,6 @@ msgstr ""
msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -3984,6 +3975,11 @@ msgstr ""
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr ""
@@ -859,10 +859,6 @@ msgstr ""
msgid "Python version:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr ""
@@ -3970,11 +3966,6 @@ msgstr ""
msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -3984,6 +3975,11 @@ msgstr ""
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr ""
@@ -897,10 +897,6 @@ msgstr "Tiempo de actividad:"
msgid "Python version:"
msgstr "Versión de Python:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "Número máximo de monitores de páginas:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "Complementos activos:"
@@ -4043,11 +4039,6 @@ msgstr "Cambios en el texto/HTML, JSON y PDF de la página web"
msgid "Detects all text changes where possible"
msgstr "Detecta todos los cambios de texto siempre que sea posible"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Límite de monitores alcanzado ({current}/{limit}). No se pueden añadir más."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -4057,6 +4048,11 @@ msgstr "Error al obtener metadatos para{}"
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "El protocolo de visualización no está permitido o el formato de URL no es válido"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Límite de visualización alcanzado ({current} /{limit} monitores). No se pueden agregar más monitores."
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Cuerpo de todas las notificaciones: puedes utilizar"
@@ -865,10 +865,6 @@ msgstr ""
msgid "Python version:"
msgstr "Version Python :"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "Nombre maximum de moniteurs de pages :"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "Plugins actifs :"
@@ -3983,11 +3979,6 @@ msgstr "Modifications du texte de la page Web/HTML, JSON et PDF"
msgid "Detects all text changes where possible"
msgstr "Détecte toutes les modifications de texte lorsque cela est possible"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Limite de moniteurs atteinte ({current}/{limit}). Impossible d'en ajouter."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -3997,6 +3988,11 @@ msgstr ""
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Corps pour toutes les notifications — Vous pouvez utiliser"
@@ -861,10 +861,6 @@ msgstr ""
msgid "Python version:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "Numero massimo di monitoraggi di pagine:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr ""
@@ -3972,11 +3968,6 @@ msgstr "Modifiche testo/HTML, JSON e PDF"
msgid "Detects all text changes where possible"
msgstr "Rileva tutte le modifiche di testo possibili"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Limite di monitoraggi raggiunto ({current}/{limit}). Impossibile aggiungerne altri."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -3986,6 +3977,11 @@ msgstr "Errore nel recupero metadati per {}"
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "Protocollo non consentito o formato URL non valido"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Corpo per tutte le notifiche — Puoi usare"
@@ -866,10 +866,6 @@ msgstr "稼働時間:"
msgid "Python version:"
msgstr "Pythonバージョン:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "ページウォッチの最大数:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "有効なプラグイン:"
@@ -3989,11 +3985,6 @@ msgstr "ウェブページのテキスト/HTML、JSONおよびPDFの変更"
msgid "Detects all text changes where possible"
msgstr "可能な限りすべてのテキスト変更を検知します"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "ウォッチの上限に達しました({current}/{limit} ウォッチ)。これ以上ウォッチを追加できません。"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -4003,6 +3994,11 @@ msgstr "{} のメタデータ取得中にエラーが発生しました"
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "ウォッチのプロトコルが許可されていないか、URL形式が無効です"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "ウォッチの上限に達しました({current}/{limit} ウォッチ)。これ以上ウォッチを追加できません。"
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "すべての通知の本文 — 以下を使用できます:"
@@ -861,10 +861,6 @@ msgstr "가동 시간:"
msgid "Python version:"
msgstr "파이썬 버전:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "최대 페이지 모니터링 수:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "활성화된 플러그인:"
@@ -3980,11 +3976,6 @@ msgstr "웹페이지 텍스트/HTML, JSON 및 PDF 변경"
msgid "Detects all text changes where possible"
msgstr "가능한 경우 모든 텍스트 변경 사항을 감지합니다."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "모니터링 한도에 도달했습니다. ({current}/{limit}개) 더 이상 모니터링을 추가할 수 없습니다."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -3994,6 +3985,11 @@ msgstr "{}의 메타데이터를 가져오는 중 오류가 발생했습니다."
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "모니터링 프로토콜이 허용되지 않거나 URL 형식이 올바르지 않습니다."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "모니터링 한도에 도달했습니다. ({current}/{limit}개) 더 이상 모니터링을 추가할 수 없습니다."
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "모든 알림 본문 — 다음을 사용할 수 있습니다:"
+7 -11
View File
@@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.60.2\n"
"Project-Id-Version: changedetection.io 0.55.8\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-09-03 09:15+0200\n"
"POT-Creation-Date: 2026-09-01 19:10+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -858,10 +858,6 @@ msgstr ""
msgid "Python version:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr ""
@@ -3969,11 +3965,6 @@ msgstr ""
msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -3983,6 +3974,11 @@ msgstr ""
msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr ""
@@ -919,10 +919,6 @@ msgstr "Czas sprawności:"
msgid "Python version:"
msgstr "Wersja Pythona:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "Maksymalna liczba obserwacji stron:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "Aktywne wtyczki:"
@@ -4131,11 +4127,6 @@ msgstr "Zmiany w treści stron internetowych (HTML), plikach JSON i PDF"
msgid "Detects all text changes where possible"
msgstr "W miarę możliwości wykrywa wszystkie zmiany w tekście"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Osiągnięto limit obserwacji ({current}/{limit}). Nie można dodać kolejnych obserwacji."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -4145,6 +4136,11 @@ msgstr "Wystąpił błąd podczas pobierania metadanych dla {}"
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "Protokół „watch” jest niedozwolony lub format adresu URL jest nieprawidłowy"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Osiągnięto limit obserwacji ({current}/{limit}). Nie można dodać kolejnych obserwacji."
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Treść wszystkich powiadomień — Można użyć"
@@ -884,10 +884,6 @@ msgstr "Tempo de atividade (Uptime):"
msgid "Python version:"
msgstr "Versão do Python:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "Número máximo de monitoramentos de páginas:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "Plugins ativos:"
@@ -4020,11 +4016,6 @@ msgstr "Mudanças em Texto/HTML de páginas, JSON e PDF"
msgid "Detects all text changes where possible"
msgstr "Detecta todas as mudanças de texto onde possível"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Limite de monitoramentos atingido ({current}/{limit}). Não é possível adicionar mais."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -4034,6 +4025,11 @@ msgstr "Erro ao buscar metadados para {}"
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "O protocolo de monitoramento não é permitido ou o formato da URL é inválido"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Limite de monitoramentos atingido ({current}/{limit}). Não é possível adicionar mais."
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Corpo para todas as notificações — Você pode usar"
@@ -898,10 +898,6 @@ msgstr "Время работы:"
msgid "Python version:"
msgstr "Версия Python:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "Максимальное количество отслеживаний страниц:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "Плагины активны:"
@@ -4088,11 +4084,6 @@ msgstr "Изменения текста веб-страницы/HTML, JSON и PD
msgid "Detects all text changes where possible"
msgstr "Обнаруживает все изменения текста, где это возможно."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Достигнут лимит отслеживаний ({current}/{limit}). Больше добавить нельзя."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -4102,6 +4093,11 @@ msgstr "Ошибка при получении метаданных для {}."
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "Протокол просмотра не разрешен или неверный формат URL."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Достигнут лимит просмотра (часы {current}/{limit}). Невозможно добавить больше часов."
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Текст для всех уведомлений — вы можете использовать"
@@ -894,10 +894,6 @@ msgstr "Çalışma Süresi:"
msgid "Python version:"
msgstr "Python sürümü:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "Maksimum sayfa izleyici sayısı:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "Aktif eklentiler:"
@@ -4023,11 +4019,6 @@ msgstr "Web Sayfası Metin/HTML, JSON ve PDF değişiklikleri"
msgid "Detects all text changes where possible"
msgstr "Mümkün olan yerlerde tüm metin değişikliklerini tespit eder"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "İzleyici sınırına ulaşıldı ({current}/{limit} izleyici). Daha fazla izleyici eklenemez."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -4037,6 +4028,11 @@ msgstr "{} için meta veri getirilirken hata oluştu"
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "İzleyici protokolüne izin verilmiyor veya geçersiz URL formatı"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "İzleyici sınırına ulaşıldı ({current}/{limit} izleyici). Daha fazla izleyici eklenemez."
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Tüm bildirimler için gövde — Bildirim başlığı, gövdesi ve URL'sinde"
@@ -874,10 +874,6 @@ msgstr ""
msgid "Python version:"
msgstr "Версія Python:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "Максимальна кількість завдань моніторингу:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "Активні плагіни:"
@@ -4002,11 +3998,6 @@ msgstr "Зміни тексту веб-сторінки/HTML, JSON та PDF"
msgid "Detects all text changes where possible"
msgstr "Виявляє всі текстові зміни, де це можливо"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Досягнуто ліміту завдань ({current}/{limit}). Неможливо додати більше."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -4016,6 +4007,11 @@ msgstr "Помилка отримання метаданих для {}"
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "Протокол завдання не дозволено або невірний формат URL"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "Досягнуто ліміту завдань ({current}/{limit}). Неможливо додати більше."
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Тіло для всіх сповіщень — Ви можете використовувати"
@@ -864,10 +864,6 @@ msgstr ""
msgid "Python version:"
msgstr "Python 版本:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "最大监视器数量:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "已启用插件:"
@@ -3976,11 +3972,6 @@ msgstr "网页文本/HTML、JSON 和 PDF 变更"
msgid "Detects all text changes where possible"
msgstr "尽可能检测所有文本变更"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "已达到监视器上限({current}/{limit})。无法添加更多。"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -3990,6 +3981,11 @@ msgstr "获取 {} 的元数据失败"
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "监控协议不允许或 URL 格式无效"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "所有通知的正文 — 您可以使用"
@@ -863,10 +863,6 @@ msgstr ""
msgid "Python version:"
msgstr "Python 版本:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of page watches:"
msgstr "最大監測任務數量:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Plugins active:"
msgstr "啟用的外掛:"
@@ -3976,11 +3972,6 @@ msgstr "網頁文字 / HTML、JSON 和 PDF 變更"
msgid "Detects all text changes where possible"
msgstr "盡可能檢測所有文字變更"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr "已達到監測任務上限({current}/{limit})。無法再新增。"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
@@ -3990,6 +3981,11 @@ msgstr "讀取 {} 的中繼資料時發生錯誤"
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "監測協定不被允許或 URL 格式無效"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({current}/{limit} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "所有通知的內文 — 您可以使用"
-4
View File
@@ -66,10 +66,6 @@ services:
# Absolute minimum seconds to recheck, overrides any watch minimum, change to 0 to disable
# - MINIMUM_SECONDS_RECHECK_TIME=3
#
# Cap the total number of watches this instance will hold - adding more (UI, API, import)
# is refused until one is deleted. Unset means unlimited.
# - PAGE_WATCH_LIMIT=100
#
# If you want to watch local files file:///path/to/file.txt (careful! security implications!)
# - ALLOW_FILE_URI=False
#
+3 -33
View File
@@ -28,7 +28,7 @@ info:
For example: `x-api-key: YOUR_API_KEY`
version: 0.1.8
version: 0.1.7
contact:
name: ChangeDetection.io
url: https://github.com/dgtlmoon/changedetection.io
@@ -299,24 +299,13 @@ components:
maxLength: 5000
tag:
type: string
deprecated: true
description: |
**Deprecated - use `tags` instead.** Kept working for API v1 and may be removed in v2.
Takes comma-separated tag *names* (not UUIDs, despite what earlier revisions of this
document said). Names are matched case-insensitively against existing tags and created
when they don't exist yet. An existing tag's UUID is also accepted and resolves to that
tag; a UUID matching no tag is ignored.
description: Tag UUID to associate with this web page change monitor (watch)
maxLength: 5000
example: "Production, Price tracking"
tags:
type: array
items:
type: string
description: |
Array of tag UUIDs. Unlike `tag`, these are applied exactly as given - no tag is ever
created from this field. The UUIDs are not validated: an unknown one is stored but
resolves to no group.
description: Array of tag UUIDs
paused:
type: boolean
description: Whether the web page change monitor (watch) is paused
@@ -1057,15 +1046,6 @@ paths:
schema:
type: string
example: "OK"
'429':
description: |
Watch limit reached (server has `PAGE_WATCH_LIMIT` set; unlimited by default).
No watch was created - delete one to make room.
content:
text/plain:
schema:
type: string
example: "Watch limit reached (50/50 watches). Cannot add more watches."
'500':
description: Server error
content:
@@ -2160,16 +2140,6 @@ paths:
type: string
format: uuid
description: List of created watch UUIDs
'429':
description: |
Watch limit reached (server has `PAGE_WATCH_LIMIT` set; unlimited by default).
The batch is rejected whole - no watches are created - so the same request can be
retried unchanged once there is room.
content:
text/plain:
schema:
type: string
example: "Watch limit reached (48/50 watches), importing 5 URL(s) would exceed it. No watches were imported."
'500':
description: Server error
+22 -83
View File
File diff suppressed because one or more lines are too long
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env python3
"""Bump the app version and refresh the translation catalogs in one step.
The release commit needs `__version__` and the .pot `Project-Id-Version` to agree
(extract_messages stamps the latter from the former). Doing them separately is easy
to half-forget, which then breaks the push *after* the release tag already exists.
Usage:
python scripts/bump_version.py 0.60.3
"""
import re
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INIT_FILE = REPO_ROOT / 'changedetectionio' / '__init__.py'
VERSION_RE = re.compile(r"""^(__version__ = )(['"])([^'"]*)\2""", re.M)
BABEL_STEPS = ['extract_messages', 'update_catalog', 'compile_catalog']
def run(*args):
print(f" $ {' '.join(args)}", flush=True)
result = subprocess.run(args, cwd=REPO_ROOT)
if result.returncode != 0:
sys.exit(f"FAILED: {' '.join(args)}")
def main(argv):
if len(argv) != 2:
sys.exit(f"Usage: python {Path(__file__).name} <new-version>\n"
f"e.g. python {Path(__file__).name} 0.60.3")
new_version = argv[1].lstrip('v')
if not re.fullmatch(r'\d+\.\d+\.\d+', new_version):
sys.exit(f"Version must look like 0.60.3, got: {new_version!r}")
source = INIT_FILE.read_text(encoding='utf-8')
match = VERSION_RE.search(source)
if not match:
sys.exit(f"Could not find __version__ in {INIT_FILE.relative_to(REPO_ROOT)}")
old_version = match.group(3)
if old_version == new_version:
sys.exit(f"Version is already {new_version}")
print(f"Bumping {old_version} -> {new_version}", flush=True)
INIT_FILE.write_text(VERSION_RE.sub(
lambda m: f"{m.group(1)}{m.group(2)}{new_version}{m.group(2)}", source, count=1),
encoding='utf-8')
print("Refreshing translation catalogs:", flush=True)
for step in BABEL_STEPS:
run(sys.executable, 'setup.py', step)
run(sys.executable, str(REPO_ROOT / 'scripts' / 'check_translations_version.py'))
print(f"\nVersion {new_version} is in sync. Changed files:")
subprocess.run(['git', 'status', '--short'], cwd=REPO_ROOT)
print("\nNothing has been committed or tagged. To release, keep this chained --")
print("a failed commit must not leave a tag pointing at the wrong revision:")
print(f" git add -u && git commit -m '{new_version}' && git tag {new_version} \\")
print(f" && git push origin master && git push origin {new_version}")
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env python3
"""Check that the version in messages.pot matches the app version.
`python setup.py extract_messages` stamps `Project-Id-Version` in the .pot
header from `changedetectionio.__version__`, so a mismatch means the version
was bumped without re-extracting the translation catalogs.
Run manually, or via the pre-push hook in .pre-commit-config.yaml.
"""
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INIT_FILE = REPO_ROOT / 'changedetectionio' / '__init__.py'
POT_FILE = REPO_ROOT / 'changedetectionio' / 'translations' / 'messages.pot'
def get_app_version():
match = re.search(r"""^__version__ = ['"]([^'"]*)['"]""",
INIT_FILE.read_text(encoding='utf-8'), re.M)
return match.group(1) if match else None
def get_pot_version():
# "Project-Id-Version: changedetection.io 0.60.2\n"
match = re.search(r'^"Project-Id-Version: changedetection\.io ([^\\"]+)',
POT_FILE.read_text(encoding='utf-8'), re.M)
return match.group(1).strip() if match else None
def main():
app_version = get_app_version()
pot_version = get_pot_version()
if not app_version:
print(f"Could not find __version__ in {INIT_FILE.relative_to(REPO_ROOT)}")
return 1
if not pot_version:
print(f"Could not find 'Project-Id-Version: changedetection.io <version>' "
f"in {POT_FILE.relative_to(REPO_ROOT)}")
return 1
if app_version != pot_version:
print(f"Translation catalog version mismatch:")
print(f" app ({INIT_FILE.relative_to(REPO_ROOT)}): {app_version}")
print(f" .pot ({POT_FILE.relative_to(REPO_ROOT)}): {pot_version}")
print()
print("Refresh the catalogs and commit the result:")
print(" python setup.py extract_messages")
print(" python setup.py update_catalog")
print(" python setup.py compile_catalog")
return 1
return 0
if __name__ == '__main__':
sys.exit(main())