mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-01 19:18:35 -04:00
Compare commits
6 Commits
3250a4ce68
...
25c9e735ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 25c9e735ef | |||
| 28c333e647 | |||
| 84709a00d9 | |||
| 578312200a | |||
| f23221420f | |||
| 6a84398e75 |
@@ -1409,7 +1409,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
|
|
||||||
# Prefer the configured DEFAULT (→ Utility) model — not the current chat
|
# Prefer the configured DEFAULT (→ Utility) model — not the current chat
|
||||||
# session's model. Fall back to the caller's session model only if unset.
|
# session's model. Fall back to the caller's session model only if unset.
|
||||||
url, model, headers = resolve_endpoint("default", owner=user)
|
url, model, headers = resolve_endpoint("utility", owner=user)
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
||||||
model = model or ((body.get("model") or "").strip() or None)
|
model = model or ((body.get("model") or "").strip() or None)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -441,4 +441,4 @@ class Skill:
|
|||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|||||||
+19
-7
@@ -1237,15 +1237,27 @@ def _anthropic_rejects_temperature(model: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
# `(?<![a-z])` anchors "opus" to a word boundary so a substring match like
|
# `(?<![a-z])` anchors "opus" to a word boundary so a substring match like
|
||||||
# `oct-opus`/`octopus-4-8` can't be read as Opus (it would otherwise strip
|
# `oct-opus`/`octopus-4-8` can't be read as Opus (it would otherwise strip
|
||||||
# temperature). Cap the minor at 1-2 digits and forbid a trailing digit so a
|
# temperature). Both version components are capped at 1-2 digits and forbid a
|
||||||
# dated id like `claude-opus-4-20250514` (Opus 4.0) parses as major-only (no
|
# trailing digit, so an 8-digit date can never be read as a version number:
|
||||||
# minor match, kept) instead of reading the date `20250514` as a giant minor
|
# `claude-opus-4-20250514` (Opus 4.0) parses as major-only rather than reading
|
||||||
# that would falsely test >= 4.7. Dated 4.7+ snapshots (`claude-opus-4-7-
|
# `20250514` as a giant minor, and `claude-3-opus-20240229` (legacy Claude 3
|
||||||
# 20260201`) keep their explicit minor and are still matched.
|
# Opus, date directly after "opus-") fails to match at all rather than reading
|
||||||
match = re.search(r"(?<![a-z])opus[-_]?(\d+)[-_.](\d{1,2})(?!\d)", model.lower())
|
# the date as a giant major. Dated 4.7+ snapshots (`claude-opus-4-7-20260201`)
|
||||||
|
# keep their explicit minor and are still matched.
|
||||||
|
#
|
||||||
|
# The minor is optional and a missing minor reads as `.0`, so major-only ids
|
||||||
|
# like `claude-opus-5` are correctly treated as >= 4.7 (issue #5753). Without
|
||||||
|
# this, every Opus 5 call kept `temperature` and failed with HTTP 400 — visible
|
||||||
|
# only on paths that pass a temperature, e.g. scheduled tasks inheriting
|
||||||
|
# `stream_agent_loop`'s 0.3 default, which returned empty responses.
|
||||||
|
match = re.search(
|
||||||
|
r"(?<![a-z])opus[-_]?(\d{1,2})(?!\d)(?:[-_.](\d{1,2})(?!\d))?", model.lower()
|
||||||
|
)
|
||||||
if not match:
|
if not match:
|
||||||
return False
|
return False
|
||||||
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
|
major = int(match.group(1))
|
||||||
|
minor = int(match.group(2)) if match.group(2) else 0
|
||||||
|
return (major, minor) >= (4, 7)
|
||||||
|
|
||||||
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
|
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
|
||||||
# API accepts "high", "medium", "low", "none" — see
|
# API accepts "high", "medium", "low", "none" — see
|
||||||
|
|||||||
+13
-7
@@ -758,30 +758,36 @@ export function mdToHtml(src, opts) {
|
|||||||
// Remove empty paragraphs
|
// Remove empty paragraphs
|
||||||
s = s.replace(/<p><\/p>/g, '');
|
s = s.replace(/<p><\/p>/g, '');
|
||||||
|
|
||||||
|
// Every restore below passes a function replacer rather than the block string
|
||||||
|
// itself. With a string replacement, `String.replace` reads `$&`, `` $` ``,
|
||||||
|
// `$'` and `$$` in the *replacement* as substitution patterns, so a restored
|
||||||
|
// block containing them is corrupted: `$&` re-inserts the placeholder, `` $` ``
|
||||||
|
// and `$'` splice in the surrounding document, and `$$` collapses to `$`. Those
|
||||||
|
// sequences are ordinary content in fenced code (`perl -pe 's/x/$& y/'`,
|
||||||
|
// `echo "$$USD"`). A function replacer inserts its return value verbatim.
|
||||||
|
|
||||||
// CRITICAL: Restore allowed HTML blocks first
|
// CRITICAL: Restore allowed HTML blocks first
|
||||||
allowedHtmlBlocks.forEach((block, index) => {
|
allowedHtmlBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___ALLOWED_HTML_${index}___`, block);
|
s = s.replace(`___ALLOWED_HTML_${index}___`, () => block);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore math blocks
|
// Restore math blocks
|
||||||
mathBlocks.forEach((block, index) => {
|
mathBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___MATH_BLOCK_${index}___`, block);
|
s = s.replace(`___MATH_BLOCK_${index}___`, () => block);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore mermaid diagram blocks
|
// Restore mermaid diagram blocks
|
||||||
mermaidBlocks.forEach((block, index) => {
|
mermaidBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___MERMAID_BLOCK_${index}___`, block);
|
s = s.replace(`___MERMAID_BLOCK_${index}___`, () => block);
|
||||||
});
|
});
|
||||||
|
|
||||||
// CRITICAL: Restore code blocks at the end
|
// CRITICAL: Restore code blocks at the end
|
||||||
codeBlocks.forEach((block, index) => {
|
codeBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___CODE_BLOCK_${index}___`, block);
|
s = s.replace(`___CODE_BLOCK_${index}___`, () => block);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore inline code spans last, so placeholders carried inside restored
|
// Restore inline code spans last, so placeholders carried inside restored
|
||||||
// <a>/allowed-HTML blocks are resolved too. The function replacer keeps the
|
// <a>/allowed-HTML blocks are resolved too.
|
||||||
// escaped code literal — e.g. a shell snippet like `echo $1` is not treated
|
|
||||||
// as a regex back-reference.
|
|
||||||
inlineCodeBlocks.forEach((block, index) => {
|
inlineCodeBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
|
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-22
@@ -3031,12 +3031,14 @@ async function initEmailAccountsSettings() {
|
|||||||
const body = {
|
const body = {
|
||||||
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
|
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
|
||||||
from_address: el('eaf-from').value.trim(),
|
from_address: el('eaf-from').value.trim(),
|
||||||
|
display_name: el('eaf-display-name').value.trim(),
|
||||||
imap_host: el('eaf-imap-host').value.trim(),
|
imap_host: el('eaf-imap-host').value.trim(),
|
||||||
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
||||||
imap_user: el('eaf-imap-user').value.trim(),
|
imap_user: el('eaf-imap-user').value.trim(),
|
||||||
imap_starttls: el('eaf-imap-starttls').checked,
|
imap_starttls: el('eaf-imap-starttls').checked,
|
||||||
smtp_host: el('eaf-smtp-host').value.trim(),
|
smtp_host: el('eaf-smtp-host').value.trim(),
|
||||||
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
|
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
|
||||||
|
smtp_security: el('eaf-smtp-security').value,
|
||||||
smtp_user: el('eaf-imap-user').value.trim(),
|
smtp_user: el('eaf-imap-user').value.trim(),
|
||||||
};
|
};
|
||||||
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
|
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
|
||||||
@@ -5788,29 +5790,30 @@ export function close() {
|
|||||||
window.history.replaceState(null, '', clean);
|
window.history.replaceState(null, '', clean);
|
||||||
const success = sp.has('email_oauth_success');
|
const success = sp.has('email_oauth_success');
|
||||||
const errMsg = sp.get('email_oauth_error') || '';
|
const errMsg = sp.get('email_oauth_error') || '';
|
||||||
// Open settings → integrations after the app has initialised.
|
// Open settings → integrations once the document is ready. This module owns
|
||||||
function _tryOpen() {
|
// the open() API, so it does not need to wait for a window-level alias.
|
||||||
if (window.settingsModule && typeof window.settingsModule.open === 'function') {
|
function _showResult() {
|
||||||
window.settingsModule.open('integrations');
|
open('integrations');
|
||||||
// Brief toast-style banner.
|
// Brief toast-style banner.
|
||||||
const banner = document.createElement('div');
|
const banner = document.createElement('div');
|
||||||
banner.textContent = success
|
banner.textContent = success
|
||||||
? '✓ Google account connected — email is ready'
|
? 'Google account connected — email is ready'
|
||||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||||
Object.assign(banner.style, {
|
Object.assign(banner.style, {
|
||||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||||
});
|
});
|
||||||
document.body.appendChild(banner);
|
document.body.appendChild(banner);
|
||||||
setTimeout(() => banner.remove(), 4000);
|
setTimeout(() => banner.remove(), 4000);
|
||||||
} else {
|
}
|
||||||
setTimeout(_tryOpen, 100);
|
if (document.readyState === 'loading') {
|
||||||
}
|
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
|
||||||
|
} else {
|
||||||
|
_showResult();
|
||||||
}
|
}
|
||||||
_tryOpen();
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Regression coverage for SMTP security saved before Google OAuth."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_tab_oauth_connect_persists_selected_smtp_security():
|
||||||
|
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
|
||||||
|
start = source.index("el('eaf-oauth-btn').addEventListener")
|
||||||
|
handler_body = source[start:source.index("if (!body.name)", start)]
|
||||||
|
|
||||||
|
assert "smtp_security: el('eaf-smtp-security').value" in handler_body
|
||||||
|
assert "display_name: el('eaf-display-name').value.trim()" in handler_body
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Regression coverage for the settings UI after Google OAuth redirects."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_redirect_uses_the_module_local_settings_api():
|
||||||
|
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
|
||||||
|
handler = source[
|
||||||
|
source.index("(function _handleOauthRedirect"):
|
||||||
|
source.index("const settingsModule =")
|
||||||
|
]
|
||||||
|
|
||||||
|
assert "open('integrations');" in handler
|
||||||
|
assert "window.settingsModule" not in handler
|
||||||
|
assert "window.__odysseusAppStarted" not in handler
|
||||||
|
assert "document.addEventListener('DOMContentLoaded', _showResult, { once: true })" in handler
|
||||||
@@ -29,6 +29,13 @@ from src.llm_core import _anthropic_rejects_temperature, _build_anthropic_payloa
|
|||||||
"anthropic/claude-opus-4-7", # tolerate a provider-prefixed id
|
"anthropic/claude-opus-4-7", # tolerate a provider-prefixed id
|
||||||
"claude-opus-4-10", # future minor still >= 4.7
|
"claude-opus-4-10", # future minor still >= 4.7
|
||||||
"claude-opus-5-0", # future major
|
"claude-opus-5-0", # future major
|
||||||
|
# Major-only ids: a missing minor reads as `.0`, so these are >= 4.7 too
|
||||||
|
# (issue #5753). Before the fix the version pattern required a minor, so
|
||||||
|
# these fell through to "accepts temperature" and every call 400'd.
|
||||||
|
"claude-opus-5",
|
||||||
|
"claude-opus-5-20260101", # major-only + dated snapshot
|
||||||
|
"anthropic/claude-opus-5", # major-only behind a provider prefix
|
||||||
|
"claude-opus-6", # future major-only
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_opus_47_plus_rejects_temperature(model):
|
def test_opus_47_plus_rejects_temperature(model):
|
||||||
@@ -48,7 +55,10 @@ def test_opus_47_plus_rejects_temperature(model):
|
|||||||
"claude-opus-4-6-20251201", # dated 4.6 snapshot — older, still keeps temperature
|
"claude-opus-4-6-20251201", # dated 4.6 snapshot — older, still keeps temperature
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
"claude-3-5-sonnet",
|
"claude-3-5-sonnet",
|
||||||
"claude-3-opus-20240229", # legacy Claude 3 Opus — no opus-N-M pattern, kept
|
"claude-3-opus-20240229", # legacy Claude 3 Opus — date directly after
|
||||||
|
# "opus-", so the major must not swallow it as version 20240229 (that is
|
||||||
|
# what makes capping the major at 1-2 digits necessary once the minor
|
||||||
|
# became optional in #5753).
|
||||||
"claude-haiku-4-5",
|
"claude-haiku-4-5",
|
||||||
"claude-x",
|
"claude-x",
|
||||||
"octopus-4-8", # "opus" only as a substring of another word — must not match
|
"octopus-4-8", # "opus" only as a substring of another word — must not match
|
||||||
@@ -87,6 +97,20 @@ def test_payload_keeps_temperature_for_older_models():
|
|||||||
assert _payload("claude-3-5-sonnet", 1.2)["temperature"] == 1.0
|
assert _payload("claude-3-5-sonnet", 1.2)["temperature"] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_omits_temperature_for_major_only_opus_5():
|
||||||
|
# Issue #5753: the scheduled-task path calls stream_agent_loop() without a
|
||||||
|
# temperature and inherits its 0.3 default, so `claude-opus-5` 400'd on every
|
||||||
|
# run and surfaced as "the model returned an empty response". Interactive chat
|
||||||
|
# leaves temperature None and never hit it.
|
||||||
|
assert "temperature" not in _payload("claude-opus-5", 0.3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_keeps_temperature_for_legacy_claude_3_opus():
|
||||||
|
# Guards the major-digit cap: `opus-20240229` must not parse as version
|
||||||
|
# 20240229, or Claude 3 Opus would silently lose the caller's temperature.
|
||||||
|
assert _payload("claude-3-opus-20240229", 0.5)["temperature"] == 0.5
|
||||||
|
|
||||||
|
|
||||||
def test_payload_keeps_temperature_for_dated_opus_4_0():
|
def test_payload_keeps_temperature_for_dated_opus_4_0():
|
||||||
# Anthropic's dated id for Opus 4.0 (claude-opus-4-20250514) is in this repo's
|
# Anthropic's dated id for Opus 4.0 (claude-opus-4-20250514) is in this repo's
|
||||||
# ANTHROPIC_MODELS list. The date must not be misread as a >= 4.7 minor, or the
|
# ANTHROPIC_MODELS list. The date must not be misread as a >= 4.7 minor, or the
|
||||||
|
|||||||
@@ -214,6 +214,50 @@ def test_inline_code_content_is_html_escaped(node_available):
|
|||||||
assert "<b>" not in html
|
assert "<b>" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_fenced_code_keeps_dollar_ampersand(node_available):
|
||||||
|
# Issue #5663: the block-restore pass used a string replacement, so `$&` in a
|
||||||
|
# restored block was read as "the matched text" and re-inserted the
|
||||||
|
# placeholder. `perl -pe 's/world/$& again/'` rendered as
|
||||||
|
# "s/world/___CODE_BLOCK_0___amp; again/" — the trailing "amp;" is the orphan
|
||||||
|
# left behind after `$&` consumed the `$&` of the escaped `$&`.
|
||||||
|
html = _run_markdown_case(
|
||||||
|
"```sh\necho \"hello world\" | perl -pe 's/world/$& again/'\n```"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "___CODE_BLOCK_" not in html
|
||||||
|
assert "s/world/$& again/" in html
|
||||||
|
assert "amp; again" not in html.replace("$& again", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fenced_code_keeps_dollar_backtick_and_quote(node_available):
|
||||||
|
# `` $` `` and `$'` splice the text before/after the placeholder into the
|
||||||
|
# block. Unlike `$&` these leave no placeholder behind — the characters just
|
||||||
|
# vanish — so assert the content survives verbatim.
|
||||||
|
html = _run_markdown_case("```sh\nsed \"s/$`/x/\" && sed \"s/$'/y/\"\n```")
|
||||||
|
|
||||||
|
assert "___CODE_BLOCK_" not in html
|
||||||
|
assert "s/$`/x/" in html
|
||||||
|
assert "s/$'/y/" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_fenced_code_keeps_double_dollar(node_available):
|
||||||
|
# `$$` collapsed to a single `$` in the restored block.
|
||||||
|
html = _run_markdown_case('```sh\necho "$$USD and $$"\n```')
|
||||||
|
|
||||||
|
assert "$$USD and $$" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_mermaid_block_keeps_dollar_ampersand(node_available):
|
||||||
|
# The mermaid restore site had the same hazard: a node label containing `$&`
|
||||||
|
# re-inserted the ___MERMAID_BLOCK_n___ placeholder into the diagram source,
|
||||||
|
# which then fails to parse. The math and allowed-HTML sites are fixed the
|
||||||
|
# same way; they need KaTeX/sanitizer conditions this harness doesn't set up.
|
||||||
|
html = _run_markdown_case('```mermaid\ngraph TD; A["$&"] --> B;\n```')
|
||||||
|
|
||||||
|
assert "___MERMAID_BLOCK_" not in html
|
||||||
|
assert "$&" in html
|
||||||
|
|
||||||
|
|
||||||
def test_currency_dollar_amounts_are_not_rendered_as_math(node_available):
|
def test_currency_dollar_amounts_are_not_rendered_as_math(node_available):
|
||||||
# "$5 to $10" used to pair the two dollar signs as inline-math delimiters
|
# "$5 to $10" used to pair the two dollar signs as inline-math delimiters
|
||||||
# and render "5 to" through KaTeX. Pandoc-style rules now reject it: the
|
# and render "5 to" through KaTeX. Pandoc-style rules now reject it: the
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Regression for issue #5697 — skill timestamps must not use ``datetime.utcnow()``.
|
||||||
|
|
||||||
|
``_now_iso()`` builds the ``created`` value in skill frontmatter. ``utcnow()``
|
||||||
|
returns a *naive* datetime and has been deprecated since Python 3.12, scheduled
|
||||||
|
for removal. The replacement must stay timezone-aware while keeping the
|
||||||
|
serialized ``YYYY-MM-DDTHH:MM:SSZ`` shape, so skill files written by older
|
||||||
|
versions keep parsing.
|
||||||
|
|
||||||
|
The UTC check matters on its own: a bare ``datetime.now()`` also produces the
|
||||||
|
right shape, but emits local wall time, which would silently backdate or
|
||||||
|
postdate skills for every user outside UTC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import warnings
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.memory.skill_format import _now_iso
|
||||||
|
|
||||||
|
_ISO_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
||||||
|
|
||||||
|
|
||||||
|
def test_now_iso_keeps_serialized_shape():
|
||||||
|
assert _ISO_Z.match(_now_iso())
|
||||||
|
|
||||||
|
|
||||||
|
def test_now_iso_emits_no_deprecation_warning():
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
_now_iso()
|
||||||
|
assert not [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not hasattr(time, "tzset"),
|
||||||
|
reason="time.tzset is unavailable on this platform",
|
||||||
|
)
|
||||||
|
def test_now_iso_is_utc_not_local_time():
|
||||||
|
"""Pin UTC under a non-UTC local timezone, where the two visibly diverge."""
|
||||||
|
original_tz = os.environ.get("TZ")
|
||||||
|
os.environ["TZ"] = "Asia/Amman" # UTC+3, never UTC
|
||||||
|
time.tzset()
|
||||||
|
try:
|
||||||
|
emitted = datetime.strptime(_now_iso(), "%Y-%m-%dT%H:%M:%SZ").replace(
|
||||||
|
tzinfo=timezone.utc
|
||||||
|
)
|
||||||
|
drift = abs((emitted - datetime.now(timezone.utc)).total_seconds())
|
||||||
|
assert drift < 60, f"timestamp is {drift}s off UTC — local time leaked in"
|
||||||
|
finally:
|
||||||
|
if original_tz is None:
|
||||||
|
os.environ.pop("TZ", None)
|
||||||
|
else:
|
||||||
|
os.environ["TZ"] = original_tz
|
||||||
|
time.tzset()
|
||||||
Reference in New Issue
Block a user