2026-03-03 11:57:18 +05:30
|
|
|
"""
|
|
|
|
|
Timezone-aware clock for Hermes.
|
|
|
|
|
|
|
|
|
|
Provides a single ``now()`` helper that returns a timezone-aware datetime
|
|
|
|
|
based on the user's configured IANA timezone (e.g. ``Asia/Kolkata``).
|
|
|
|
|
|
|
|
|
|
Resolution order:
|
|
|
|
|
1. ``HERMES_TIMEZONE`` environment variable
|
|
|
|
|
2. ``timezone`` key in ``~/.hermes/config.yaml``
|
|
|
|
|
3. Falls back to the server's local time (``datetime.now().astimezone()``)
|
|
|
|
|
|
|
|
|
|
Invalid timezone values log a warning and fall back safely — Hermes never
|
|
|
|
|
crashes due to a bad timezone string.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
chore: remove ~100 unused imports across 55 files (#3016)
Automated cleanup via pyflakes + autoflake with manual review.
Changes:
- Removed unused stdlib imports (os, sys, json, pathlib.Path, etc.)
- Removed unused typing imports (List, Dict, Any, Optional, Tuple, Set, etc.)
- Removed unused internal imports (hermes_cli.auth, hermes_cli.config, etc.)
- Fixed cli.py: removed 8 shadowed banner imports (imported from hermes_cli.banner
then immediately redefined locally — only build_welcome_banner is actually used)
- Added noqa comments to imports that appear unused but serve a purpose:
- Re-exports (gateway/session.py SessionResetPolicy, tools/terminal_tool.py
is_interrupted/_interrupt_event)
- SDK presence checks in try/except (daytona, fal_client, discord)
- Test mock targets (auxiliary_client.py Path, mcp_config.py get_hermes_home)
Zero behavioral changes. Full test suite passes (6162/6162, 2 pre-existing
streaming test failures unrelated to this change).
2026-03-25 15:02:03 -07:00
|
|
|
from datetime import datetime
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
from hermes_constants import get_config_path
|
2026-03-03 11:57:18 +05:30
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
except ImportError:
|
|
|
|
|
# Python 3.8 fallback (shouldn't be needed — Hermes requires 3.9+)
|
|
|
|
|
from backports.zoneinfo import ZoneInfo # type: ignore[no-redef]
|
|
|
|
|
|
|
|
|
|
# Cached state — resolved once, reused on every call.
|
|
|
|
|
# Call reset_cache() to force re-resolution (e.g. after config changes).
|
|
|
|
|
_cached_tz: Optional[ZoneInfo] = None
|
|
|
|
|
_cached_tz_name: Optional[str] = None
|
|
|
|
|
_cache_resolved: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_timezone_name() -> str:
|
|
|
|
|
"""Read the configured IANA timezone string (or empty string).
|
|
|
|
|
|
|
|
|
|
This does file I/O when falling through to config.yaml, so callers
|
|
|
|
|
should cache the result rather than calling on every ``now()``.
|
|
|
|
|
"""
|
|
|
|
|
# 1. Environment variable (highest priority — set by Supervisor, etc.)
|
|
|
|
|
tz_env = os.getenv("HERMES_TIMEZONE", "").strip()
|
|
|
|
|
if tz_env:
|
|
|
|
|
return tz_env
|
|
|
|
|
|
|
|
|
|
# 2. config.yaml ``timezone`` key
|
|
|
|
|
try:
|
|
|
|
|
import yaml
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
config_path = get_config_path()
|
2026-03-03 11:57:18 +05:30
|
|
|
if config_path.exists():
|
codebase: add encoding='utf-8' to all bare open() calls (PLW1514)
Closes the last Python-on-Windows UTF-8 exposure by making every
text-mode open() call explicit about its encoding.
Before: on Windows, bare open(path, 'r') defaults to the system
locale encoding (cp1252 on US-locale installs). That means reading
any config/yaml/markdown/json file with non-ASCII content either
crashes with UnicodeDecodeError or silently mis-decodes bytes.
After: all 89 affected call sites in production code now pass
encoding='utf-8' explicitly. Works identically on every platform
and every locale, no surprise behavior.
Mechanical sweep via:
ruff check --preview --extend-select PLW1514 --unsafe-fixes --fix --exclude 'tests,venv,.venv,node_modules,website,optional-skills, skills,tinker-atropos,plugins' .
All 89 fixes have the same shape: open(x) or open(x, mode) became
open(x, encoding='utf-8') or open(x, mode, encoding='utf-8'). Nothing
else changed. Every modified file still parses and the Windows/sandbox
test suite is still green (85 passed, 14 skipped, 0 failed across
tests/tools/test_code_execution_windows_env.py +
tests/tools/test_code_execution_modes.py + tests/tools/test_env_passthrough.py +
tests/test_hermes_bootstrap.py).
Scope notes:
- tests/ excluded: test fixtures can use locale encoding intentionally
(exercising edge cases). If we want to tighten tests later that's
a separate PR.
- plugins/ excluded: plugin-specific conventions may differ; plugin
authors own their code.
- optional-skills/ and skills/ excluded: skill scripts are user-authored
and we don't want to mass-edit them.
- website/ and tinker-atropos/ excluded: vendored / generated content.
46 files touched, 89 +/- lines (symmetric replacement). No behavior
change on POSIX or on Windows when the file is ASCII; bug fix on
Windows when the file contains non-ASCII.
2026-05-07 19:24:45 -07:00
|
|
|
with open(config_path, encoding="utf-8") as f:
|
2026-03-03 11:57:18 +05:30
|
|
|
cfg = yaml.safe_load(f) or {}
|
fix(managed-scope): honor managed scope in all standalone config loaders
The skin bug was one instance of a class: several subsystems build their
config dict directly from config.yaml instead of routing through
hermes_cli.config.load_config (which carries the managed merge), so they
silently ignored administrator-pinned values. Audited every config.yaml
reader and fixed the behavioral-read bypasses:
- gateway/config.py load_gateway_config (messaging gateway: session_reset,
quick_commands, stt, model, ...)
- gateway/run.py _load_gateway_config (its read_raw_config fast path also
skipped the merge — read_raw_config returns raw user YAML)
- tui_gateway/server.py _load_cfg (new TUI + desktop backend: skin,
reasoning_effort, service_tier, provider_routing)
- cron/scheduler.py (scheduled-job model/reasoning/toolsets/provider_routing)
- hermes_logging.py (logging.level/max_size_mb/backup_count)
- hermes_time.py (timezone)
- hermes_cli/doctor.py (memory-provider diagnostic reads effective config)
All route through a new shared managed_scope.apply_managed_overlay() helper
that mirrors _load_config_impl (env-only expansion so a user ${VAR} can't
shadow a managed literal, root-model-string normalization, leaf-merge) and is
fail-open. cli.py's earlier inline fix is refactored onto the same helper.
Write-back paths (slash_commands, telegram/yuanbao dm_topics, profile
distribution) are deliberately left reading raw user YAML — overlaying managed
values there would persist them into the user file. The dashboard
(web_server.py) already routes through load_config and needed no change.
TUI loader caches the RAW config so _save_cfg never writes managed values to
disk. Adds test_managed_scope_overlay.py (helper) and
test_managed_scope_loaders.py (per-surface integration); mutation-checked.
2026-06-19 13:01:48 +10:00
|
|
|
# Managed scope: an administrator can pin ``timezone`` too. Overlay
|
|
|
|
|
# via the shared helper (fail-open) since this reads config.yaml directly.
|
|
|
|
|
try:
|
|
|
|
|
from hermes_cli import managed_scope
|
|
|
|
|
cfg = managed_scope.apply_managed_overlay(cfg)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-03-03 11:57:18 +05:30
|
|
|
tz_cfg = cfg.get("timezone", "")
|
|
|
|
|
if isinstance(tz_cfg, str) and tz_cfg.strip():
|
|
|
|
|
return tz_cfg.strip()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_zoneinfo(name: str) -> Optional[ZoneInfo]:
|
|
|
|
|
"""Validate and return a ZoneInfo, or None if invalid."""
|
|
|
|
|
if not name:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return ZoneInfo(name)
|
|
|
|
|
except (KeyError, Exception) as exc:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Invalid timezone '%s': %s. Falling back to server local time.",
|
|
|
|
|
name, exc,
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_timezone() -> Optional[ZoneInfo]:
|
|
|
|
|
"""Return the user's configured ZoneInfo, or None (meaning server-local).
|
|
|
|
|
|
|
|
|
|
Resolved once and cached. Call ``reset_cache()`` after config changes.
|
|
|
|
|
"""
|
|
|
|
|
global _cached_tz, _cached_tz_name, _cache_resolved
|
|
|
|
|
if not _cache_resolved:
|
|
|
|
|
_cached_tz_name = _resolve_timezone_name()
|
|
|
|
|
_cached_tz = _get_zoneinfo(_cached_tz_name)
|
|
|
|
|
_cache_resolved = True
|
|
|
|
|
return _cached_tz
|
|
|
|
|
|
|
|
|
|
|
2026-06-07 22:08:01 -07:00
|
|
|
def reset_cache() -> None:
|
|
|
|
|
"""Clear the cached timezone so the next call re-resolves it.
|
|
|
|
|
|
|
|
|
|
Call this after the configured timezone may have changed (e.g. after a
|
|
|
|
|
config edit or ``HERMES_TIMEZONE`` update) to force ``get_timezone()`` /
|
|
|
|
|
``now()`` to read the new value instead of the value cached at first use.
|
|
|
|
|
"""
|
|
|
|
|
global _cached_tz, _cached_tz_name, _cache_resolved
|
|
|
|
|
_cached_tz = None
|
|
|
|
|
_cached_tz_name = None
|
|
|
|
|
_cache_resolved = False
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 11:57:18 +05:30
|
|
|
def now() -> datetime:
|
|
|
|
|
"""
|
|
|
|
|
Return the current time as a timezone-aware datetime.
|
|
|
|
|
|
|
|
|
|
If a valid timezone is configured, returns wall-clock time in that zone.
|
|
|
|
|
Otherwise returns the server's local time (via ``astimezone()``).
|
|
|
|
|
"""
|
|
|
|
|
tz = get_timezone()
|
|
|
|
|
if tz is not None:
|
|
|
|
|
return datetime.now(tz)
|
|
|
|
|
# No timezone configured — use server-local (still tz-aware)
|
|
|
|
|
return datetime.now().astimezone()
|
|
|
|
|
|
|
|
|
|
|