diff --git a/api_server.py b/api_server.py index 16d176e..bdf8cad 100644 --- a/api_server.py +++ b/api_server.py @@ -44,6 +44,8 @@ from file_io import (DB_PATH, init_db_sync, get_all_media_file_ids_sync, remove_media_file_ids_sync, get_mime_type_sync, set_mime_type_sync) from tg_cache import cleanup_legacy_cache_files, sweep_tgcache +from channel_key import canonical_channel_key +from migrate_channel_keys import migrate_channel_keys_sync # Global python-magic instance for MIME type detection magic_mime = magic.Magic(mime=True) @@ -270,6 +272,12 @@ async def lifespan(_: FastAPI): # Initialize SQLite database (creates table if not present) await asyncio.to_thread(init_db_sync, DB_PATH) + # One-shot migration of existing cache dirs + DB rows to the canonical channel key + # (case-insensitive username collapse). Runs AFTER init_db_sync and BEFORE client.start() + # and the background tasks. Wrapped in to_thread because it is a blocking FS-rename + + # SQLite routine that would otherwise stall the event loop. Idempotent: a re-run is a no-op. + await asyncio.to_thread(migrate_channel_keys_sync, DB_PATH, MEDIA_CACHE_DIR) + # One-shot startup maintenance of the history/chatinfo cache: drop legacy pickle files # (which are now always a miss), age-sweep stale tgcache entries, and remove the # pre-SQLite data/media_file_ids.json legacy dump (no longer read by any code). @@ -1311,30 +1319,32 @@ async def get_media(channel: str, post_id: int, file_unique_id: str, request: Re #else: # logger.info(f"Valid digest for media {url}: {digest}") - # Convert numeric channel ID to int if needed - channel_id: Union[str, int] = channel - if isinstance(channel, str) and channel.startswith('-100'): - channel_id = int(channel) - + # Canonical filesystem/DB/API key. Computed AFTER the digest check (which must run + # against the ORIGINAL url string) so 'Durov', 'durov' and '@durov' collapse to one + # on-disk tree, one _access_updates/media_key identity and one download identity. The + # canonical form is API-safe: usernames are case-insensitive on Telegram's side and + # numeric '-100...' ids are preserved verbatim (download_media_file re-ints them). + fs_channel = canonical_channel_key(channel) + try: # Wrap the download and prepare call import time as _time # Pre-semaphore cache check: serve already-cached files without acquiring the semaphore - cache_path = media_cache_path(str(channel), post_id, file_unique_id) + cache_path = media_cache_path(fs_channel, post_id, file_unique_id) if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0: # File is already in cache — skip semaphore and serve directly - logger.info(f"pre_semaphore_cache_hit: {channel}/{post_id}/{file_unique_id}") + logger.info(f"pre_semaphore_cache_hit: {fs_channel}/{post_id}/{file_unique_id}") # Record the access time into the accumulator instead of firing a per-hit - # SQLite write. Key channel as str(channel) — see _access_updates. - _access_updates[(str(channel), post_id, file_unique_id)] = datetime.now().timestamp() + # SQLite write. Key channel as the canonical fs_channel — see _access_updates. + _access_updates[(fs_channel, post_id, file_unique_id)] = datetime.now().timestamp() return await prepare_file_response(cache_path, request=request, - media_key=(str(channel), post_id, file_unique_id)) + media_key=(fs_channel, post_id, file_unique_id)) # A file that recently kept failing is in backoff: fast-reject instead of # occupying a scarce download slot (and a Pyrogram transmission permit) on a # request that will very likely hang again. Cached files already returned above, # so this only guards the live-download path. - backoff_remaining = _download_backoff_remaining((str(channel), post_id, file_unique_id)) + backoff_remaining = _download_backoff_remaining((fs_channel, post_id, file_unique_id)) if backoff_remaining > 0: logger.info(f"media_backoff_skip: {channel}/{post_id}/{file_unique_id} in backoff {backoff_remaining:.0f}s") return Response(status_code=503, content="Media temporarily unavailable, retry later", @@ -1369,7 +1379,7 @@ async def get_media(channel: str, post_id: int, file_unique_id: str, request: Re if _sem_wait > 0.5: logger.warning(f"diag_semaphore_wait: {channel}/{post_id}/{file_unique_id} waited {_sem_wait:.3f}s for HTTP_DOWNLOAD_SEMAPHORE") _dl_start = _time.monotonic() - file_path, delete_after = await _download_deduped(channel_id, post_id, file_unique_id) + file_path, delete_after = await _download_deduped(fs_channel, post_id, file_unique_id) _dl_elapsed = _time.monotonic() - _dl_start logger.info(f"diag_download_timing: {channel}/{post_id}/{file_unique_id} download_media_file took {_dl_elapsed:.3f}s (semaphore_wait={_sem_wait:.3f}s)") finally: @@ -1378,7 +1388,7 @@ async def get_media(channel: str, post_id: int, file_unique_id: str, request: Re raise HTTPException(status_code=404, detail="File not found") if file_path: return await prepare_file_response(file_path, request=request, delete_after=delete_after, - media_key=(str(channel), post_id, file_unique_id)) + media_key=(fs_channel, post_id, file_unique_id)) except ZeroSizeFileError as e: # Catch zero-size file errors logger.warning(f"zero_size_file_encountered: {str(e)}. Instructing client to retry.") return Response( diff --git a/channel_key.py b/channel_key.py new file mode 100644 index 0000000..976b56d --- /dev/null +++ b/channel_key.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# flake8: noqa +# pylint: disable=missing-function-docstring + +"""Canonical channel key. + +Dependency-free helper shared by tg_cache, post_parser and api_server. Kept free of +any project imports so it can be imported by all three layers without introducing a +circular import. +""" + +from typing import Union + + +def canonical_channel_key(channel: Union[str, int]) -> str: + """Canonical cache/DB key for a channel. + + Telegram usernames are case-insensitive -> lowercase them. + Numeric '-100...' ids keep their exact string form. The '@' prefix is stripped. + The canonical form is also SAFE for Telegram API calls (usernames case-insensitive + on the API side; numeric unchanged) -- callers may thread one value through both + filesystem paths and API calls. + """ + s = str(channel).strip().lstrip('@') + if s.startswith('-100') and s[4:].isdigit(): + return s + return s.lower() diff --git a/migrate_channel_keys.py b/migrate_channel_keys.py new file mode 100644 index 0000000..def4aab --- /dev/null +++ b/migrate_channel_keys.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# flake8: noqa +# pylint: disable=missing-function-docstring, broad-exception-caught, logging-fstring-interpolation + +"""One-shot migration of existing cache data to the canonical channel key. + +Collapses non-canonical (mixed-case) channel names in both the on-disk media cache +(data/cache//...) and the SQLite media_file_ids table onto their canonical, +lowercase form. Numeric '-100...' ids are already canonical and are never touched. + +Unit of work = a WHOLE channel, processed FS FIRST then SQL. The order is essential: +if the FS rename/merge fails, the channel's SQL rows are left old-cased so the old tree +is still reachable by the media sweeper via its DB rows (no eternal orphan directory). + +The migration is idempotent: a second run finds nothing to do and is a no-op. +""" + +import os +import shutil +import sqlite3 +import logging + +from channel_key import canonical_channel_key + +logger = logging.getLogger(__name__) + + +def _merge_dir_tree(src: str, dst: str) -> None: + """Merge every file under ``src`` into ``dst`` where an existing ``dst`` file WINS. + + Both trees share the same filesystem (siblings under the media cache root), so files + are moved with os.rename. Any file that already exists in ``dst`` is kept and the + ``src`` copy is discarded. Emptied ``src`` remnants are removed at the end. + """ + for root, _dirs, files in os.walk(src): + rel = os.path.relpath(root, src) + target_root = dst if rel == '.' else os.path.join(dst, rel) + os.makedirs(target_root, exist_ok=True) + for name in files: + s = os.path.join(root, name) + t = os.path.join(target_root, name) + if os.path.exists(t): + os.remove(s) # existing file in dst wins + else: + os.rename(s, t) + shutil.rmtree(src, ignore_errors=True) + + +def migrate_channel_keys_sync(db_path: str, cache_dir: str) -> None: + """Migrate mixed-case channel cache dirs and DB rows onto the canonical key. + + Blocking (FS renames + SQLite). Call via asyncio.to_thread from an async context. + """ + rows_merged = 0 + dirs_renamed = 0 + samefile_noops = 0 + failures = 0 + + conn = sqlite3.connect(db_path) + conn.execute("PRAGMA busy_timeout = 5000") + try: + # ------------------------------------------------------------------ # + # Candidate channels: mixed-case first-level cache dirs UNION mixed-case DB rows. + # ------------------------------------------------------------------ # + candidates: set[str] = set() + try: + for entry in os.listdir(cache_dir): + full = os.path.join(cache_dir, entry) + if not os.path.isdir(full): + continue + if entry.startswith('-100'): + continue + if entry != entry.lower(): + candidates.add(entry) + except FileNotFoundError: + pass # No cache dir yet — nothing to migrate on the FS side. + + try: + cur = conn.execute( + "SELECT DISTINCT channel FROM media_file_ids " + "WHERE channel != lower(channel) AND channel NOT LIKE '-100%'" + ) + for (ch,) in cur.fetchall(): + candidates.add(ch) + except sqlite3.Error as e: + logger.error(f"migrate_channel_keys: DB candidate query failed: {e}") + + for name in sorted(candidates): + canonical = canonical_channel_key(name) + if canonical == name: + # Nothing to change (already canonical) — defensive, candidates shouldn't hit this. + continue + try: + # ---------------------------- FS step ---------------------------- # + src = os.path.join(cache_dir, name) + dst = os.path.join(cache_dir, canonical) + if os.path.isdir(src): + try: + if os.path.exists(dst) and os.path.samefile(src, dst): + # Case-insensitive FS (macOS/APFS, Docker Desktop for Mac): + # src and dst are ONE directory. Renaming/merging would destroy + # the channel's entire cache — treat as a pure no-op. + samefile_noops += 1 + elif not os.path.exists(dst): + os.rename(src, dst) + dirs_renamed += 1 + else: + # Genuinely different dirs (case-sensitive FS, both forms present): + # per-file merge with the existing dst file winning. + _merge_dir_tree(src, dst) + dirs_renamed += 1 + except OSError as e: + # FS step failed (EACCES etc.): mark orphan candidate and SKIP the SQL + # step so the old-cased DB rows keep the old tree visible to the sweeper. + logger.error( + f"migrate_channel_keys: FS step failed for {name!r} -> {canonical!r} " + f"(orphan candidate, SQL skipped): {e}" + ) + failures += 1 + continue + + # ---------------------------- SQL step --------------------------- # + old_rows = conn.execute( + "SELECT post_id, file_unique_id, added, mime_type " + "FROM media_file_ids WHERE channel = ?", + (name,), + ).fetchall() + for (post_id, file_unique_id, added, mime_type) in old_rows: + twin = conn.execute( + "SELECT added, mime_type FROM media_file_ids " + "WHERE channel = ? AND post_id = ? AND file_unique_id = ?", + (canonical, post_id, file_unique_id), + ).fetchone() + if twin is None: + # No lowercase twin: a plain re-key is safe (no PK conflict). + conn.execute( + "UPDATE media_file_ids SET channel = ? " + "WHERE channel = ? AND post_id = ? AND file_unique_id = ?", + (canonical, name, post_id, file_unique_id), + ) + else: + # Twin exists: merge (added = max, prefer non-NULL mime_type) into the + # canonical row, then delete the old-cased row. A bare + # UPDATE ... SET channel=lower(channel) is FORBIDDEN (PK conflict). + t_added, t_mime = twin + new_added = max(added, t_added) + new_mime = t_mime if t_mime is not None else mime_type + conn.execute( + "UPDATE media_file_ids SET added = ?, mime_type = ? " + "WHERE channel = ? AND post_id = ? AND file_unique_id = ?", + (new_added, new_mime, canonical, post_id, file_unique_id), + ) + conn.execute( + "DELETE FROM media_file_ids " + "WHERE channel = ? AND post_id = ? AND file_unique_id = ?", + (name, post_id, file_unique_id), + ) + rows_merged += 1 + conn.commit() + except Exception as e: + # Never crash startup: log and move on. A re-run is an idempotent no-op. + try: + conn.rollback() + except Exception: + pass + logger.error(f"migrate_channel_keys: channel {name!r} migration failed: {e}") + failures += 1 + finally: + conn.close() + + logger.info( + f"migration_summary: rows merged {rows_merged}, dirs renamed {dirs_renamed}, " + f"samefile no-ops {samefile_noops}, failures {failures}" + ) diff --git a/post_parser.py b/post_parser.py index ad1e61e..91f92ce 100644 --- a/post_parser.py +++ b/post_parser.py @@ -1416,8 +1416,8 @@ class PostParser: if hasattr(chat, 'usernames') and chat.usernames: # Check many usernames active_usernames = [u.username for u in chat.usernames if u.active] if active_usernames: - return active_usernames[0] - if hasattr(chat, 'username') and chat.username: return chat.username # Check single username + return active_usernames[0].lower() # Canonicalize: usernames are case-insensitive + if hasattr(chat, 'username') and chat.username: return chat.username.lower() # Check single username (canonicalized) # Return numeric ID only if no username found if isinstance(chat.id, int) and str(chat.id).startswith('-100'): return str(chat.id) diff --git a/tests/test_channel_key.py b/tests/test_channel_key.py new file mode 100644 index 0000000..7ed272f --- /dev/null +++ b/tests/test_channel_key.py @@ -0,0 +1,261 @@ +# flake8: noqa +# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring +# pylint: disable=redefined-outer-name, line-too-long +# pylance: disable=reportMissingImports, reportMissingModuleSource +"""Tests for the canonical channel key: helper, the three wiring layers, and the +one-shot migration (issue #24, tasks 8-11).""" + +import os +import sqlite3 +from types import SimpleNamespace + +import pytest + +import tg_cache +from channel_key import canonical_channel_key +from migrate_channel_keys import migrate_channel_keys_sync +from post_parser import PostParser + + +# --------------------------------------------------------------------------- # +# Task 11 — unit: canonical_channel_key. +# --------------------------------------------------------------------------- # +def test_canonical_lowercases_username(): + assert canonical_channel_key('Durov') == 'durov' + + +def test_canonical_strips_at_prefix(): + assert canonical_channel_key('@durov') == 'durov' + assert canonical_channel_key('@Durov') == 'durov' + + +def test_canonical_numeric_id_str_preserved(): + assert canonical_channel_key('-1001234567890') == '-1001234567890' + + +def test_canonical_numeric_id_int_preserved(): + assert canonical_channel_key(-1001234567890) == '-1001234567890' + + +def test_canonical_already_lowercase_idempotent(): + assert canonical_channel_key('durov') == 'durov' + assert canonical_channel_key(canonical_channel_key('Durov')) == 'durov' + + +# --------------------------------------------------------------------------- # +# Task 11 — tg_cache path is identical for the two casings. +# --------------------------------------------------------------------------- # +def test_tgcache_path_identical_for_casings(tmp_path, monkeypatch): + monkeypatch.setattr(tg_cache, "CACHE_DIR", str(tmp_path)) + p_upper = tg_cache._cache_file_path('Durov', 'history.json') + p_lower = tg_cache._cache_file_path('durov', 'history.json') + p_at = tg_cache._cache_file_path('@DUROV', 'history.json') + assert p_upper == p_lower == p_at + assert os.path.basename(p_upper) == 'durov.history.json' + + +def test_tgcache_path_numeric_preserved(tmp_path, monkeypatch): + monkeypatch.setattr(tg_cache, "CACHE_DIR", str(tmp_path)) + p = tg_cache._cache_file_path('-1001234567890', 'chatinfo.json') + assert os.path.basename(p) == '-1001234567890.chatinfo.json' + + +# --------------------------------------------------------------------------- # +# Task 11 — get_channel_username lowercases both branches. +# --------------------------------------------------------------------------- # +def _parser(): + return PostParser(SimpleNamespace()) + + +def test_get_channel_username_single_lowercased(): + parser = _parser() + msg = SimpleNamespace(chat=SimpleNamespace(username='MixedCase', id=1)) + assert parser.get_channel_username(msg) == 'mixedcase' + + +def test_get_channel_username_usernames_list_lowercased(): + parser = _parser() + chat = SimpleNamespace( + usernames=[SimpleNamespace(username='MixedCase', active=True)], + username=None, + id=1, + ) + assert parser.get_channel_username(SimpleNamespace(chat=chat)) == 'mixedcase' + + +def test_get_channel_username_numeric_id_unchanged(): + parser = _parser() + chat = SimpleNamespace(usernames=None, username=None, id=-1001234567890) + assert parser.get_channel_username(SimpleNamespace(chat=chat)) == '-1001234567890' + + +# --------------------------------------------------------------------------- # +# Migration helpers. +# --------------------------------------------------------------------------- # +def _make_db(path): + conn = sqlite3.connect(path) + conn.execute( + """CREATE TABLE media_file_ids ( + channel TEXT NOT NULL, + post_id INTEGER NOT NULL, + file_unique_id TEXT NOT NULL, + added REAL NOT NULL, + mime_type TEXT, + PRIMARY KEY (channel, post_id, file_unique_id) + )""" + ) + conn.commit() + conn.close() + + +def _rows(path): + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + rows = [dict(r) for r in conn.execute( + "SELECT channel, post_id, file_unique_id, added, mime_type FROM media_file_ids " + "ORDER BY channel, post_id, file_unique_id")] + conn.close() + return rows + + +# --------------------------------------------------------------------------- # +# Task 11 (a) — SQL merge of both forms → one row, max(added), non-NULL mime_type. +# --------------------------------------------------------------------------- # +def test_migration_sql_merge(tmp_path): + db = str(tmp_path / "m.db") + cache = tmp_path / "cache" + cache.mkdir() + _make_db(db) + conn = sqlite3.connect(db) + # Old-cased row (higher added, NULL mime) and lowercase twin (lower added, has mime). + conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('Durov', 5, 'fid', 200.0, None)) + conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('durov', 5, 'fid', 100.0, 'image/jpeg')) + conn.commit() + conn.close() + + migrate_channel_keys_sync(db, str(cache)) + + rows = _rows(db) + assert len(rows) == 1 + r = rows[0] + assert r['channel'] == 'durov' + assert r['added'] == 200.0 # max(added) + assert r['mime_type'] == 'image/jpeg' # non-NULL preferred + + +def test_migration_sql_rekey_no_twin(tmp_path): + db = str(tmp_path / "m.db") + cache = tmp_path / "cache" + cache.mkdir() + _make_db(db) + conn = sqlite3.connect(db) + conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('Durov', 7, 'fid', 50.0, 'video/mp4')) + conn.commit() + conn.close() + + migrate_channel_keys_sync(db, str(cache)) + + rows = _rows(db) + assert len(rows) == 1 + assert rows[0]['channel'] == 'durov' + assert rows[0]['mime_type'] == 'video/mp4' + + +# --------------------------------------------------------------------------- # +# Task 11 (b) — samefile guard → no-op, data intact (case-insensitive FS simulated). +# --------------------------------------------------------------------------- # +def test_migration_samefile_guard_no_data_loss(tmp_path, monkeypatch): + db = str(tmp_path / "m.db") + cache = tmp_path / "cache" + cache.mkdir() + _make_db(db) + # Single dir 'Durov' with a file; on a case-insensitive FS 'durov' would be the same dir. + src = cache / "Durov" / "5" + src.mkdir(parents=True) + (src / "fid").write_bytes(b"payload") + + # Simulate a case-insensitive FS: any existence check on the lowercase twin resolves, + # and samefile reports src == dst so the FS step must be a pure no-op. + real_exists = os.path.exists + + def fake_exists(p): + if os.path.basename(p) == 'durov': + return True + return real_exists(p) + + monkeypatch.setattr(os.path, "exists", fake_exists) + monkeypatch.setattr(os.path, "samefile", lambda a, b: True) + + migrate_channel_keys_sync(db, str(cache)) + + # Data intact: the original file must still be present and unchanged. + assert (cache / "Durov" / "5" / "fid").read_bytes() == b"payload" + + +# --------------------------------------------------------------------------- # +# Task 11 (c) — merge of two genuinely different dirs → combined, target wins. +# --------------------------------------------------------------------------- # +def test_migration_fs_merge_different_dirs(tmp_path): + db = str(tmp_path / "m.db") + cache = tmp_path / "cache" + cache.mkdir() + _make_db(db) + + # Old-cased tree. + (cache / "Durov" / "5").mkdir(parents=True) + (cache / "Durov" / "5" / "shared").write_bytes(b"OLD") # collides -> dst wins + (cache / "Durov" / "5" / "only_old").write_bytes(b"OLD_ONLY") + # Canonical tree. + (cache / "durov" / "5").mkdir(parents=True) + (cache / "durov" / "5" / "shared").write_bytes(b"NEW") # existing dst wins + (cache / "durov" / "6").mkdir(parents=True) + (cache / "durov" / "6" / "only_new").write_bytes(b"NEW_ONLY") + + migrate_channel_keys_sync(db, str(cache)) + + # Old dir gone, everything merged under 'durov'. + assert not (cache / "Durov").exists() + assert (cache / "durov" / "5" / "shared").read_bytes() == b"NEW" # target wins + assert (cache / "durov" / "5" / "only_old").read_bytes() == b"OLD_ONLY" # brought over + assert (cache / "durov" / "6" / "only_new").read_bytes() == b"NEW_ONLY" # untouched + + +def test_migration_fs_rename_when_dst_missing(tmp_path): + db = str(tmp_path / "m.db") + cache = tmp_path / "cache" + cache.mkdir() + _make_db(db) + (cache / "Durov" / "5").mkdir(parents=True) + (cache / "Durov" / "5" / "fid").write_bytes(b"data") + + migrate_channel_keys_sync(db, str(cache)) + + assert not (cache / "Durov").exists() + assert (cache / "durov" / "5" / "fid").read_bytes() == b"data" + + +# --------------------------------------------------------------------------- # +# Task 11 (d) — re-run is a no-op. +# --------------------------------------------------------------------------- # +def test_migration_rerun_noop(tmp_path): + db = str(tmp_path / "m.db") + cache = tmp_path / "cache" + cache.mkdir() + _make_db(db) + conn = sqlite3.connect(db) + conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('Durov', 5, 'fid', 200.0, None)) + conn.execute("INSERT INTO media_file_ids VALUES (?,?,?,?,?)", ('durov', 5, 'fid', 100.0, 'image/jpeg')) + conn.commit() + conn.close() + (cache / "Durov" / "5").mkdir(parents=True) + (cache / "Durov" / "5" / "fid").write_bytes(b"data") + + migrate_channel_keys_sync(db, str(cache)) + first = _rows(db) + # Second run: nothing left to migrate. + migrate_channel_keys_sync(db, str(cache)) + second = _rows(db) + + assert first == second + assert all(r['channel'] == r['channel'].lower() for r in second) + assert (cache / "durov" / "5" / "fid").read_bytes() == b"data" diff --git a/tg_cache.py b/tg_cache.py index c61c535..26304bd 100644 --- a/tg_cache.py +++ b/tg_cache.py @@ -27,6 +27,7 @@ from message_snapshot import ( snapshot_messages, restore_messages, ) +from channel_key import canonical_channel_key logger = logging.getLogger(__name__) @@ -51,11 +52,15 @@ def _safe_key(key: Union[str, int]) -> str: def _cache_file_path(key: Union[str, int], suffix: str) -> str: - """Return the cache file path . (e.g. 'history.json' / 'chatinfo.json').""" + """Return the cache file path . (e.g. 'history.json' / 'chatinfo.json'). + + The key is first canonicalized so that 'Durov', 'durov' and '@durov' collapse to a + single cache file (Telegram usernames are case-insensitive). + """ if not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR, exist_ok=True) logger.info(f"cache_dir_created: path {CACHE_DIR}") - return os.path.join(CACHE_DIR, f"{_safe_key(key)}.{suffix}") + return os.path.join(CACHE_DIR, f"{_safe_key(canonical_channel_key(key))}.{suffix}") # --------------------------------------------------------------------------- #