Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Hevelius Runner Changelog

0.3.0 (unreleased)

- Exclude patterns - specified files or directories can be excluded from the volumes handling.
- Mass rename implemented (`hevelius-runner volumes rename "SUBSTRING1" "SUBSTRING2"`)

0.2.0 (2026-04-28)

- YAML configuration with `run`, `config`, and `doctor` subcommands; redacted `config` output
Expand Down
6 changes: 6 additions & 0 deletions bin/hevelius-runner.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
set "PROJECT_ROOT=%SCRIPT_DIR%.."
set PYTHONUTF8=1
"%PROJECT_ROOT%\venv\Scripts\python.exe" "%PROJECT_ROOT%\src\hevelius-runner.py" %*
37 changes: 37 additions & 0 deletions bin/install-hevelius.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
param(
[string]$ShortcutName = "Hevelius Runner"
)

$ErrorActionPreference = "Stop"

$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$binDir = Join-Path $repoRoot "bin"

$runnerCmdPath = Join-Path $binDir "hevelius-runner.cmd"
$openShellPath = Join-Path $binDir "open-hevelius.ps1"

$missing = @()
if (-not (Test-Path $runnerCmdPath)) { $missing += $runnerCmdPath }
if (-not (Test-Path $openShellPath)) { $missing += $openShellPath }
if ($missing.Count -gt 0) {
Write-Error "Required launcher file(s) missing from the repository:`n $($missing -join "`n ")`nRun 'git restore bin/' to restore them, then re-run this script."
exit 1
}

$startMenuPrograms = [Environment]::GetFolderPath("Programs")
$shortcutPath = Join-Path $startMenuPrograms "$ShortcutName.lnk"
$powershellExe = "powershell.exe"
$shortcutArgs = "-NoExit -ExecutionPolicy Bypass -File `"$openShellPath`""

$wshShell = New-Object -ComObject WScript.Shell
$shortcut = $wshShell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $powershellExe
$shortcut.Arguments = $shortcutArgs
$shortcut.WorkingDirectory = $repoRoot
$shortcut.IconLocation = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe,0"
$shortcut.Save()

Write-Host "Start Menu shortcut created:" -ForegroundColor Green
Write-Host " $shortcutPath"
Write-Host ""
Write-Host "Open Start Menu, find '$ShortcutName', and pin it if desired."
15 changes: 15 additions & 0 deletions bin/open-hevelius.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
Set-Location $repoRoot

$activateScript = Join-Path $repoRoot "venv\Scripts\Activate.ps1"
if (Test-Path $activateScript) {
. $activateScript
}
else {
Write-Warning "Virtual environment activation script not found at: $activateScript"
}

$runnerCmd = Join-Path $repoRoot "bin\hevelius-runner.cmd"
Set-Alias -Name hevelius-runner -Value $runnerCmd -Scope Global

Write-Host "Hevelius runner shell ready. Use: hevelius-runner <args>" -ForegroundColor Green
5 changes: 5 additions & 0 deletions config/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ paths:
repo-path: data/repo
template_dir: templates
output_dir: output
# Optional full-path exclusion patterns for volumes/list/file scans.
# If a file's full path matches any pattern, it is ignored completely.
exclude_patterns:
- '*FLAT*'
- '*shit*'
# One or more FITS watch locations; each volume can define an optional nickname.
volumes:
- path: 'C:\astro\fits-live'
Expand Down
6 changes: 6 additions & 0 deletions doc/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ This software requires Python. Once python is installed, get the sources into a

To check if it works, you can run `python src/hevelius-runner.py doctor`.

Optionally, you can run bin/install-hevelius.ps1. This should create a hevelius runner
shortcut in the start menu.

# Running hevelius-runner

Expand All @@ -23,3 +25,7 @@ To check if it works, you can run `python src/hevelius-runner.py doctor`.
3. venv\Scripts\activate
4. python src/hevelius-runner.py <commands>
```

or

click on the hevelius runner if you ran the install-hevelius script.
17 changes: 14 additions & 3 deletions doc/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ task will be created. Files can be assigned to one of known projects and then th
project's statistics will be updated, if `--projects` option is specified. Finally,
there's `--orphans` option to list all files that were not able to assign to any project.

## Excluding files from volumes scan

`hevelius-runner volumes` can skip files based on full-path patterns from
`config/config.yaml`:

```yaml
paths:
exclude_patterns:
- '*FLAT*'
- '*shit*'
```

If a discovered file path matches any pattern, that file is ignored completely
and is not processed (no header read, no task/project updates).

## Run

Expand All @@ -88,9 +102,6 @@ Run the automation loop:
python src/hevelius-runner.py run
```




The `run` command will:
1. Load configuration
2. Execute startup scripts
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
PyYAML
colorama>=0.4.6
requests
psycopg2-binary
watchdog
Expand Down
138 changes: 90 additions & 48 deletions src/cmd_doctor.py
Original file line number Diff line number Diff line change
@@ -1,76 +1,118 @@
import logging
import os
import sys
from pathlib import Path
from typing import Any, Callable

from api_client import APIClient
from config_manager import ConfigManager
from console_color import GREEN, RED, color_segment
from cmd_volumes import _monitor_volumes_from_config

logger = logging.getLogger(__name__)


def _ok() -> str:
return color_segment(GREEN, "OK", sys.stdout)


def _fail() -> str:
return color_segment(RED, "FAILED", sys.stdout)


def cmd_doctor(cm: ConfigManager, api_client_factory: Callable[[dict], Any] = APIClient) -> int:

# The api_client_factory is used in tests, to override the actual APIClient class with a mock.

exit_code = 0
api_cfg = cm.get_api_config()
required_api = ("base_url", "timeout", "username", "password")
missing = [k for k in required_api if not str(api_cfg.get(k, "")).strip()]
if missing:
print(
f"API configuration is incomplete (missing: {', '.join(missing)}).",
file=sys.stderr,
)
logger.error(f"API config — missing: {', '.join(missing)} {_fail()}")
return 1

client = api_client_factory(api_cfg)
version_ok = False
try:
version = client.get_version()
print(f"API reachable at {api_cfg['base_url'].rstrip('/')}/ - backend version: {version}")
logger.info(f"API reachable — {api_cfg['base_url'].rstrip('/')}/ (version {version}) {_ok()}")
version_ok = True
except Exception as e:
print(f"API check failed (version endpoint): {e}", file=sys.stderr)
return 1
exit_code = 1
logger.error(f"API reachable — unreachable: {e} {_fail()}")

try:
login = client.login()
if not login.status or not login.token:
print(f"Login failed: {login.msg or 'no JWT token in response'}", file=sys.stderr)
return 1
print(f"Authenticated as {api_cfg['username']!r} (user id {login.user_id}).")
except Exception as e:
print(f"API check failed (login): {e}", file=sys.stderr)
return 1
login_ok = False
if version_ok:
try:
login = client.login()
if not login.status or not login.token:
exit_code = 1
logger.error(f"Login — {login.msg or 'no JWT token in response'} {_fail()}")
else:
login_ok = True
logger.info(f"Login — authenticated as {api_cfg['username']!r} (user id {login.user_id}) {_ok()}")
except Exception as e:
exit_code = 1
logger.error(f"Login — {e} {_fail()}")
else:
logger.info("Login — skipped (API not reached)")

try:
scopes = client.list_telescopes()
print(f"Telescopes available: {len(scopes)}")
sid = api_cfg.get("scope_id")
if sid is not None and str(sid).strip() != "":
want = int(sid)
ids = {int(t.get("scope_id")) for t in scopes if t.get("scope_id") is not None}
if want not in ids:
print(
f"Configured api.scope_id={want} is not in the telescope list from the API.",
file=sys.stderr,
)
return 1
names = [t.get("name") for t in scopes if int(t.get("scope_id", -1)) == want]
label = names[0] if names else "?"
print(f"Configured scope_id {want} OK ({label!r}).")
else:
print(
"Note: api.scope_id is not set; use `telescope list` and `telescope set`.",
file=sys.stderr,
)
except Exception as e:
print(f"API check failed (telescope list): {e}", file=sys.stderr)
return 1
if version_ok and login_ok:
try:
scopes = client.list_telescopes()
sid = api_cfg.get("scope_id")
if sid is None or not str(sid).strip():
logger.info("Telescope — api.scope_id not set; use `telescope list` and `telescope set`")
else:
want = int(sid)
ids = {int(t.get("scope_id")) for t in scopes if t.get("scope_id") is not None}
if want not in ids:
exit_code = 1
logger.error(
f"Telescope — scope_id={want} not found in API list ({len(scopes)} telescope(s)) {_fail()}"
)
else:
names = [t.get("name") for t in scopes if int(t.get("scope_id", -1)) == want]
label = names[0] if names else "?"
logger.info(
f"Telescope — scope_id={want} ({label!r}), {len(scopes)} telescope(s) available {_ok()}"
)
except Exception as e:
exit_code = 1
logger.error(f"Telescope — {e} {_fail()}")
elif version_ok:
logger.info("Telescope — skipped (not authenticated)")
else:
logger.info("Telescope — skipped (API not reached)")

nina_cfg = cm.get_nina_config()
exe = nina_cfg.get("executable_path", "")
nina_path = Path(str(exe)) if exe else None
if not nina_path or not nina_path.is_file():
print(
f"NINA executable not found or not configured: {exe!r}",
file=sys.stderr,
)
return 1
print(f"NINA executable found: {nina_path}")
exit_code = 1
logger.error(f"NINA — executable not found: {exe!r} {_fail()}")
else:
logger.info(f"NINA — {nina_path} {_ok()}")

volumes = _monitor_volumes_from_config(cm)
if not volumes:
logger.info("Volumes — none configured")
else:
for path, nickname in volumes:
if not os.path.isdir(path):
exit_code = 1
logger.error(f"Volume '{nickname}' — {path} {_fail()} (directory not found)")
else:
try:
os.listdir(path)
logger.info(f"Volume '{nickname}' — {path} {_ok()}")
except OSError as e:
exit_code = 1
logger.error(f"Volume '{nickname}' — {path} {_fail()} ({e})")

print("Configuration and connectivity check passed.")
return 0
if exit_code == 0:
logger.info(f"All checks passed {_ok()}")
else:
logger.error(f"Doctor — one or more checks failed (see above) {_fail()}")
return exit_code
Loading
Loading