- 源/仓库各自可在直连(FTP/SFTP、MySQL)与 Metrix 存储/数据库平台间独立选择,两侧互不依赖 - Metrix 模式下源走平台储存 API、仓库走平台导入 + run-script(single_session)、查看导出代理到平台 - 去掉对外 API 文档与 API Token(前后端 + auth/config 解耦),业务接口仅登录态可访问 - 授权默认到期日改为 2026-12-30 - 设置页卡片改横向自适应(宽屏并排、窄屏换行),处理历史保留卡片收窄
667 lines
25 KiB
Python
667 lines
25 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import threading
|
||
from collections import defaultdict
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime, timedelta
|
||
from typing import Any
|
||
|
||
from fastapi import HTTPException
|
||
|
||
from app import state
|
||
from app.config import CACHE_DIR, AutoSchedulerConfig
|
||
from app.services.platform import make_source_downloader
|
||
from app.services.remote_download import RemoteDataDownloader, RemoteFileInfo
|
||
from app.utils.file_dates import parse_file_date_range, required_week_days
|
||
|
||
|
||
READY_DIR = CACHE_DIR / "auto_scheduler"
|
||
READY_FLAG = READY_DIR / "ready.flag"
|
||
DISABLED_CHECK_SECONDS = 60
|
||
STARTUP_CHECK_SECONDS = 5
|
||
FAILURE_RESULTS = {"scan_failed", "trigger_failed", "source_cleanup_failed", "failed", "invalid_flag"}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RJDirectoryReadyStatus:
|
||
"""RJ 数据目录就绪状态,按最新文件自动识别日粒度或周粒度。"""
|
||
directory: str
|
||
ready: bool
|
||
granularity: str | None = None
|
||
found_days: list[date] | None = None
|
||
missing_days: list[date] | None = None
|
||
file_name: str | None = None
|
||
file_count: int = 0
|
||
error: str | None = None
|
||
skipped: bool = False
|
||
skip_reason: str | None = None
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
found_days = self.found_days or []
|
||
missing_days = self.missing_days or []
|
||
return {
|
||
"ready": self.ready,
|
||
"granularity": self.granularity,
|
||
"found_days": [item.isoformat() for item in found_days],
|
||
"missing_days": [item.isoformat() for item in missing_days],
|
||
"found_count": len(found_days),
|
||
"required_count": len(found_days) + len(missing_days),
|
||
"file_name": self.file_name,
|
||
"file_count": self.file_count,
|
||
"error": self.error,
|
||
"skipped": self.skipped,
|
||
"skip_reason": self.skip_reason,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DirectoryReadyStatus:
|
||
directory: str
|
||
ready: bool
|
||
found_days: list[date]
|
||
missing_days: list[date]
|
||
file_count: int
|
||
error: str | None = None
|
||
skipped: bool = False
|
||
skip_reason: str | None = None
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
return {
|
||
"ready": self.ready,
|
||
"found_days": [item.isoformat() for item in self.found_days],
|
||
"missing_days": [item.isoformat() for item in self.missing_days],
|
||
"found_count": len(self.found_days),
|
||
"required_count": len(self.found_days) + len(self.missing_days),
|
||
"file_count": self.file_count,
|
||
"error": self.error,
|
||
"skipped": self.skipped,
|
||
"skip_reason": self.skip_reason,
|
||
}
|
||
|
||
|
||
class AutoScheduler:
|
||
def __init__(self) -> None:
|
||
self._stop_event = threading.Event()
|
||
self._check_lock = threading.Lock()
|
||
self._status_lock = threading.Lock()
|
||
self._thread: threading.Thread | None = None
|
||
self._running = False
|
||
self._last_check_at: datetime | None = None
|
||
self._next_check_at: datetime | None = None
|
||
self._last_result = "not_started"
|
||
self._last_message = "自动调度器尚未检查"
|
||
self._directory_status: dict[str, dict[str, Any]] = {}
|
||
self._task_running = False
|
||
self._task_id: str | None = None
|
||
self._failure_count = 0
|
||
|
||
def start(self) -> None:
|
||
if self._thread and self._thread.is_alive():
|
||
return
|
||
self._running = True
|
||
self._stop_event.clear()
|
||
self._set_next_check(datetime.now() + timedelta(seconds=STARTUP_CHECK_SECONDS))
|
||
self._thread = threading.Thread(target=self._run_loop, name="auto-scheduler", daemon=True)
|
||
self._thread.start()
|
||
|
||
def stop(self) -> None:
|
||
self._running = False
|
||
self._stop_event.set()
|
||
if self._thread and self._thread.is_alive():
|
||
self._thread.join(timeout=5)
|
||
|
||
def get_status(self) -> dict[str, Any]:
|
||
app_config = state.current_config()
|
||
config = app_config.remote_data.normalized()
|
||
scheduler = config.auto_scheduler.normalized()
|
||
rj_config = app_config.rj_data.normalized()
|
||
target_days = required_week_days(scheduler.week_offset)
|
||
ready_flag = self._read_ready_flag()
|
||
|
||
with self._status_lock:
|
||
return {
|
||
"enabled": scheduler.enabled,
|
||
"running": self._running,
|
||
"check_interval_hours": scheduler.check_interval_hours,
|
||
"expected_directories": scheduler.expected_directories,
|
||
"week_offset": scheduler.week_offset,
|
||
"auto_delete_source": config.auto_delete_source,
|
||
"rj_data_enabled": rj_config.enabled,
|
||
"rj_directories": rj_config.weekly_directories,
|
||
"rj_weekly_directories": rj_config.weekly_directories,
|
||
"next_check_at": self._format_dt(self._next_check_at),
|
||
"last_check_at": self._format_dt(self._last_check_at),
|
||
"last_result": self._last_result,
|
||
"last_message": self._last_message,
|
||
"failure_count": self._failure_count,
|
||
"task_running": self._task_running,
|
||
"task_id": self._task_id,
|
||
"ready_flag": ready_flag,
|
||
"target_week": {
|
||
"start": target_days[0].isoformat(),
|
||
"end": target_days[-1].isoformat(),
|
||
"days": [item.isoformat() for item in target_days],
|
||
},
|
||
"directory_status": self._directory_status,
|
||
}
|
||
|
||
def check_and_run(self, manual: bool = False) -> dict[str, Any]:
|
||
if not self._check_lock.acquire(blocking=False):
|
||
return self._finish_check("busy", "自动调度器正在检查中", manual)
|
||
|
||
try:
|
||
return self._check_and_run_locked(manual)
|
||
finally:
|
||
self._check_lock.release()
|
||
|
||
def _run_loop(self) -> None:
|
||
while not self._stop_event.wait(self._seconds_until_next_check()):
|
||
self.check_and_run(manual=False)
|
||
|
||
def _check_and_run_locked(self, manual: bool) -> dict[str, Any]:
|
||
now = datetime.now()
|
||
app_config = state.current_config()
|
||
remote_config = app_config.remote_data.normalized()
|
||
scheduler = remote_config.auto_scheduler.normalized()
|
||
self._last_check_at = now
|
||
|
||
if not scheduler.enabled:
|
||
self._set_next_check(now + timedelta(seconds=DISABLED_CHECK_SECONDS))
|
||
return self._finish_check("disabled", "自动调度未启用", manual)
|
||
|
||
self._set_next_check(now + timedelta(hours=scheduler.check_interval_hours))
|
||
|
||
if not remote_config.enabled:
|
||
return self._finish_check("remote_disabled", "远程数据源未启用", manual)
|
||
|
||
if state.global_task_lock["locked"]:
|
||
return self._finish_check("task_running", "已有任务在运行,本轮自动调度跳过", manual)
|
||
|
||
ready_flag = self._read_ready_flag()
|
||
if ready_flag["exists"]:
|
||
if ready_flag.get("invalid"):
|
||
self._clear_ready_flag()
|
||
return self._finish_check("invalid_flag", "就绪标识格式错误,已清除,本轮不触发处理", manual)
|
||
target_dates = self._target_dates_from_flag(ready_flag, scheduler)
|
||
return self._trigger_processing(target_dates, ready_flag, manual)
|
||
|
||
target_days = required_week_days(scheduler.week_offset)
|
||
downloader = make_source_downloader(app_config)
|
||
rj_config = app_config.rj_data.normalized()
|
||
rj_directories = set(rj_config.weekly_directories) if rj_config.enabled else set()
|
||
|
||
# 检查现有7天目录(4G/5G数据),RJ 目录单独按粒度判断,避免被普通7天规则误拦。
|
||
directory_status = self._check_remote_ready(
|
||
downloader,
|
||
scheduler,
|
||
target_days,
|
||
excluded_directories=rj_directories,
|
||
)
|
||
|
||
error_count = sum(1 for item in directory_status.values() if item.error)
|
||
if error_count:
|
||
self._set_combined_directory_status(directory_status, {})
|
||
return self._finish_check(
|
||
"scan_failed",
|
||
f"远程目录扫描失败,{error_count}/{len(directory_status)} 个目录无法访问或扫描失败",
|
||
manual,
|
||
)
|
||
|
||
active_status = [item for item in directory_status.values() if not item.skipped]
|
||
skipped_count = len(directory_status) - len(active_status)
|
||
daily_ready = bool(active_status) and all(item.ready for item in active_status)
|
||
|
||
# 检查 RJ 数据目录,按最新文件自动判断日粒度或周粒度。
|
||
rj_status = self._check_rj_ready(downloader, target_days)
|
||
rj_error_count = sum(1 for item in rj_status.values() if item.error)
|
||
self._set_combined_directory_status(directory_status, rj_status)
|
||
if rj_error_count:
|
||
return self._finish_check(
|
||
"scan_failed",
|
||
f"RJ 远程目录扫描失败,{rj_error_count}/{len(rj_status)} 个目录无法访问或扫描失败",
|
||
manual,
|
||
)
|
||
|
||
active_rj_status = [item for item in rj_status.values() if not item.skipped]
|
||
rj_ready = all(item.ready for item in active_rj_status)
|
||
rj_status_text = ""
|
||
|
||
if active_rj_status:
|
||
rj_ready_count = sum(1 for item in active_rj_status if item.ready)
|
||
rj_total = len(active_rj_status)
|
||
if not rj_ready:
|
||
rj_status_text = f",RJ 数据 {rj_ready_count}/{rj_total} 个有效目录就绪"
|
||
|
||
# 两个条件都满足才触发
|
||
if daily_ready and rj_ready:
|
||
self._mark_ready(target_days, directory_status, rj_status)
|
||
skipped_text = f",已跳过 {skipped_count} 个停推目录" if skipped_count else ""
|
||
rj_text = ",RJ 数据已就绪" if active_rj_status else ""
|
||
return self._finish_check(
|
||
"marked_ready",
|
||
f"远程数据已满足目标周 7 天{skipped_text}{rj_text},已写入就绪标识,下次检查将自动处理",
|
||
manual,
|
||
)
|
||
|
||
if not active_status and directory_status:
|
||
return self._finish_check(
|
||
"waiting",
|
||
f"远程数据未就绪,{len(directory_status)} 个目录均为空,已视为停推但不会触发处理",
|
||
manual,
|
||
)
|
||
|
||
if not active_status:
|
||
return self._finish_check(
|
||
"waiting",
|
||
f"远程普通日数据未发现有效目录,无法触发处理{rj_status_text}",
|
||
manual,
|
||
)
|
||
|
||
ready_count = sum(1 for item in active_status if item.ready)
|
||
skipped_text = f",跳过 {skipped_count} 个停推目录" if skipped_count else ""
|
||
return self._finish_check(
|
||
"waiting",
|
||
f"远程数据未就绪,{ready_count}/{len(active_status)} 个有效目录满足目标周 7 天{skipped_text}{rj_status_text}",
|
||
manual,
|
||
)
|
||
|
||
def _check_remote_ready(
|
||
self,
|
||
downloader: RemoteDataDownloader,
|
||
scheduler: AutoSchedulerConfig,
|
||
target_days: list[date],
|
||
excluded_directories: set[str] | None = None,
|
||
) -> dict[str, DirectoryReadyStatus]:
|
||
excluded = {self._normalize_directory_name(item) for item in (excluded_directories or set())}
|
||
expected_directories = scheduler.expected_directories
|
||
if expected_directories:
|
||
return {
|
||
directory: self._directory_ready_status(
|
||
directory,
|
||
self._safe_list_remote_zip_files(downloader, directory),
|
||
target_days,
|
||
)
|
||
for directory in expected_directories
|
||
if not self._is_excluded_directory(directory, excluded)
|
||
}
|
||
|
||
files = self._safe_list_remote_zip_files(downloader, None)
|
||
if files is None:
|
||
return {
|
||
".": DirectoryReadyStatus(
|
||
directory=".",
|
||
ready=False,
|
||
found_days=[],
|
||
missing_days=target_days,
|
||
file_count=0,
|
||
error="远程目录扫描失败",
|
||
)
|
||
}
|
||
|
||
if not files:
|
||
return {
|
||
".": DirectoryReadyStatus(
|
||
directory=".",
|
||
ready=False,
|
||
found_days=[],
|
||
missing_days=target_days,
|
||
file_count=0,
|
||
error="远程目录未找到 ZIP 文件",
|
||
)
|
||
}
|
||
|
||
grouped: dict[str, list[RemoteFileInfo]] = defaultdict(list)
|
||
for remote_file in files:
|
||
parent = remote_file.parent or "."
|
||
if self._is_excluded_directory(parent, excluded):
|
||
continue
|
||
grouped[parent].append(remote_file)
|
||
|
||
if not grouped:
|
||
return {}
|
||
|
||
return {
|
||
directory: self._directory_ready_status(directory, directory_files, target_days)
|
||
for directory, directory_files in sorted(grouped.items(), key=lambda item: item[0])
|
||
}
|
||
|
||
def _safe_list_remote_zip_files(
|
||
self,
|
||
downloader: RemoteDataDownloader,
|
||
directory: str | None,
|
||
) -> list[RemoteFileInfo] | None:
|
||
try:
|
||
return downloader.list_remote_zip_files(directory)
|
||
except Exception:
|
||
return None
|
||
|
||
def _directory_ready_status(
|
||
self,
|
||
directory: str,
|
||
files: list[RemoteFileInfo] | None,
|
||
target_days: list[date],
|
||
) -> DirectoryReadyStatus:
|
||
if files is None:
|
||
return DirectoryReadyStatus(
|
||
directory=directory,
|
||
ready=False,
|
||
found_days=[],
|
||
missing_days=target_days,
|
||
file_count=0,
|
||
error="远程目录不存在或无法访问",
|
||
)
|
||
|
||
if not files:
|
||
return DirectoryReadyStatus(
|
||
directory=directory,
|
||
ready=True,
|
||
found_days=[],
|
||
missing_days=[],
|
||
file_count=0,
|
||
skipped=True,
|
||
skip_reason="目录为空,视为已停推并跳过",
|
||
)
|
||
|
||
required = set(target_days)
|
||
found = {
|
||
target_day
|
||
for remote_file in files
|
||
if (date_range := parse_file_date_range(remote_file.name))
|
||
for target_day in date_range.covered_days()
|
||
if target_day in required
|
||
}
|
||
missing = [item for item in target_days if item not in found]
|
||
return DirectoryReadyStatus(
|
||
directory=directory,
|
||
ready=not missing,
|
||
found_days=sorted(found),
|
||
missing_days=missing,
|
||
file_count=len(files),
|
||
)
|
||
|
||
def _check_rj_ready(
|
||
self,
|
||
downloader: RemoteDataDownloader,
|
||
target_days: list[date],
|
||
) -> dict[str, RJDirectoryReadyStatus]:
|
||
"""检查 RJ 数据目录是否就绪,自动识别目录最新文件是日粒度还是周粒度。"""
|
||
rj_config = state.current_config().rj_data.normalized()
|
||
if not rj_config.enabled:
|
||
return {}
|
||
|
||
result: dict[str, RJDirectoryReadyStatus] = {}
|
||
|
||
for directory in rj_config.weekly_directories:
|
||
result[directory] = self._check_single_rj_directory(downloader, directory, target_days)
|
||
|
||
return result
|
||
|
||
def _check_single_rj_directory(
|
||
self,
|
||
downloader: RemoteDataDownloader,
|
||
directory: str,
|
||
target_days: list[date],
|
||
) -> RJDirectoryReadyStatus:
|
||
"""检查单个 RJ 目录是否包含目标周数据。"""
|
||
files = self._safe_list_remote_zip_files(downloader, directory)
|
||
if files is None:
|
||
return RJDirectoryReadyStatus(
|
||
directory=directory,
|
||
ready=False,
|
||
error="远程目录不存在或无法访问",
|
||
)
|
||
|
||
if not files:
|
||
return RJDirectoryReadyStatus(
|
||
directory=directory,
|
||
ready=True,
|
||
file_count=0,
|
||
skipped=True,
|
||
skip_reason="目录为空,视为已停推并跳过",
|
||
)
|
||
|
||
parsed_files = [
|
||
(remote_file, date_range)
|
||
for remote_file in files
|
||
if (date_range := parse_file_date_range(remote_file.name))
|
||
]
|
||
if not parsed_files:
|
||
return RJDirectoryReadyStatus(
|
||
directory=directory,
|
||
ready=False,
|
||
found_days=[],
|
||
missing_days=target_days,
|
||
file_count=len(files),
|
||
error="目录中未找到可识别日期的 ZIP 文件",
|
||
)
|
||
|
||
latest_file, latest_range = max(
|
||
parsed_files,
|
||
key=lambda item: (item[1].start, item[1].end_exclusive, item[0].name),
|
||
)
|
||
granularity = "daily" if latest_range.span_days <= 1 else "weekly"
|
||
required = set(target_days)
|
||
|
||
found = {
|
||
target_day
|
||
for _, date_range in parsed_files
|
||
for target_day in date_range.covered_days()
|
||
if target_day in required
|
||
}
|
||
missing = [item for item in target_days if item not in found]
|
||
|
||
if granularity == "daily":
|
||
return RJDirectoryReadyStatus(
|
||
directory=directory,
|
||
ready=not missing,
|
||
granularity=granularity,
|
||
found_days=sorted(found),
|
||
missing_days=missing,
|
||
file_name=latest_file.name,
|
||
file_count=len(files),
|
||
)
|
||
|
||
for remote_file in files:
|
||
date_range = parse_file_date_range(remote_file.name)
|
||
if date_range and date_range.covers_all(required):
|
||
return RJDirectoryReadyStatus(
|
||
directory=directory,
|
||
ready=True,
|
||
granularity=granularity,
|
||
found_days=target_days,
|
||
missing_days=[],
|
||
file_name=remote_file.name,
|
||
file_count=len(files),
|
||
)
|
||
|
||
return RJDirectoryReadyStatus(
|
||
directory=directory,
|
||
ready=False,
|
||
granularity=granularity,
|
||
found_days=sorted(found),
|
||
missing_days=missing,
|
||
file_name=latest_file.name,
|
||
file_count=len(files),
|
||
)
|
||
|
||
def _trigger_processing(
|
||
self,
|
||
target_dates: list[date],
|
||
ready_flag: dict[str, Any],
|
||
manual: bool,
|
||
) -> dict[str, Any]:
|
||
from app.api.routers.remote import start_remote_processing_job
|
||
|
||
try:
|
||
result = start_remote_processing_job(
|
||
source="scheduler",
|
||
on_finish=self._on_processing_finish,
|
||
target_dates=target_dates,
|
||
)
|
||
except HTTPException as exc:
|
||
return self._finish_check("trigger_failed", str(exc.detail), manual)
|
||
except Exception as exc:
|
||
return self._finish_check("trigger_failed", f"自动调度触发失败: {exc}", manual)
|
||
|
||
task_id = result.get("task_id")
|
||
with self._status_lock:
|
||
self._task_running = True
|
||
self._task_id = str(task_id) if task_id else None
|
||
self._last_result = "triggered"
|
||
self._last_message = "已根据就绪标识触发远程下载并处理"
|
||
return {
|
||
"success": True,
|
||
"result": "triggered",
|
||
"message": "已根据就绪标识触发远程下载并处理",
|
||
"task_id": task_id,
|
||
"ready_flag": ready_flag,
|
||
"status": self.get_status(),
|
||
}
|
||
|
||
def _mark_ready(
|
||
self,
|
||
target_days: list[date],
|
||
directory_status: dict[str, DirectoryReadyStatus],
|
||
rj_status: dict[str, RJDirectoryReadyStatus] | None = None,
|
||
) -> None:
|
||
READY_DIR.mkdir(parents=True, exist_ok=True)
|
||
payload = {
|
||
"ready_at": datetime.now().isoformat(timespec="seconds"),
|
||
"week_start": target_days[0].isoformat(),
|
||
"week_end": target_days[-1].isoformat(),
|
||
"target_dates": [item.isoformat() for item in target_days],
|
||
"directories": {
|
||
name: item.to_dict()
|
||
for name, item in directory_status.items()
|
||
},
|
||
}
|
||
if rj_status:
|
||
payload["rj_directories"] = {
|
||
name: item.to_dict()
|
||
for name, item in rj_status.items()
|
||
}
|
||
payload["rj_weekly_directories"] = {
|
||
name: item.to_dict()
|
||
for name, item in rj_status.items()
|
||
}
|
||
READY_FLAG.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
def _clear_ready_flag(self) -> None:
|
||
try:
|
||
READY_FLAG.unlink(missing_ok=True)
|
||
except OSError:
|
||
pass
|
||
|
||
def _read_ready_flag(self) -> dict[str, Any]:
|
||
if not READY_FLAG.exists():
|
||
return {"exists": False}
|
||
try:
|
||
data = json.loads(READY_FLAG.read_text(encoding="utf-8"))
|
||
if isinstance(data, dict):
|
||
return {"exists": True, **data}
|
||
except Exception as exc:
|
||
return {"exists": True, "invalid": True, "error": str(exc)}
|
||
return {"exists": True, "invalid": True, "error": "ready.flag 格式错误"}
|
||
|
||
def _target_dates_from_flag(
|
||
self,
|
||
ready_flag: dict[str, Any],
|
||
scheduler: AutoSchedulerConfig,
|
||
) -> list[date]:
|
||
raw_dates = ready_flag.get("target_dates")
|
||
if isinstance(raw_dates, list):
|
||
dates: list[date] = []
|
||
for raw_date in raw_dates:
|
||
try:
|
||
dates.append(date.fromisoformat(str(raw_date)))
|
||
except ValueError:
|
||
continue
|
||
if dates:
|
||
return sorted(dates)
|
||
return required_week_days(scheduler.week_offset)
|
||
|
||
def _on_processing_finish(self, task_id: str, status: str) -> None:
|
||
if status == "completed":
|
||
self._clear_ready_flag()
|
||
message = "自动调度任务处理成功,已清除就绪标识"
|
||
result = "completed"
|
||
else:
|
||
message = "自动调度任务未完成,就绪标识已保留,下次检查会重试"
|
||
result = status or "failed"
|
||
|
||
with self._status_lock:
|
||
self._task_running = False
|
||
self._task_id = task_id
|
||
self._last_result = result
|
||
self._last_message = message
|
||
self._failure_count = 0 if result == "completed" else self._failure_count + 1
|
||
|
||
def _finish_check(self, result: str, message: str, manual: bool) -> dict[str, Any]:
|
||
with self._status_lock:
|
||
self._last_result = result
|
||
self._last_message = message
|
||
if result in FAILURE_RESULTS:
|
||
self._failure_count += 1
|
||
elif result not in {"busy", "task_running"}:
|
||
self._failure_count = 0
|
||
if result not in {"triggered", "task_running"}:
|
||
self._task_running = False
|
||
|
||
return {
|
||
"success": result not in FAILURE_RESULTS,
|
||
"manual": manual,
|
||
"result": result,
|
||
"message": message,
|
||
"status": self.get_status(),
|
||
}
|
||
|
||
def _set_combined_directory_status(
|
||
self,
|
||
directory_status: dict[str, DirectoryReadyStatus],
|
||
rj_status: dict[str, RJDirectoryReadyStatus],
|
||
) -> None:
|
||
with self._status_lock:
|
||
combined = {
|
||
name: item.to_dict()
|
||
for name, item in directory_status.items()
|
||
}
|
||
combined.update(
|
||
{
|
||
name: item.to_dict()
|
||
for name, item in rj_status.items()
|
||
}
|
||
)
|
||
self._directory_status = combined
|
||
|
||
@staticmethod
|
||
def _normalize_directory_name(directory: str) -> str:
|
||
normalized = str(directory or "").replace("\\", "/").strip().strip("/")
|
||
return "." if normalized in {"", "."} else normalized
|
||
|
||
@classmethod
|
||
def _is_excluded_directory(cls, directory: str, excluded: set[str]) -> bool:
|
||
if not excluded:
|
||
return False
|
||
normalized = cls._normalize_directory_name(directory)
|
||
return any(
|
||
normalized == item or normalized.startswith(f"{item}/")
|
||
for item in excluded
|
||
if item != "."
|
||
)
|
||
|
||
def _set_next_check(self, value: datetime) -> None:
|
||
with self._status_lock:
|
||
self._next_check_at = value
|
||
|
||
def _seconds_until_next_check(self) -> float:
|
||
with self._status_lock:
|
||
next_check_at = self._next_check_at
|
||
if not next_check_at:
|
||
return STARTUP_CHECK_SECONDS
|
||
return max((next_check_at - datetime.now()).total_seconds(), 0)
|
||
|
||
@staticmethod
|
||
def _format_dt(value: datetime | None) -> str | None:
|
||
return value.isoformat(timespec="seconds") if value else None
|