Skip to content
Closed
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ src/panoptes/utils/

## Critical Info

**Config Server:** Port 8765 (check if in use)
**Config Server:** Port 6563 (check if in use)
**Test Config:** `tests/testing.yaml`
**Timing:** Never cancel - env setup (1-3 min), tests (2-5 min), deps (5-8 min)

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,11 @@ The configuration server provides a REST API for centralized configuration manag
panoptes-config-server run --config-file tests/testing.yaml

# With custom host/port
panoptes-config-server --host 0.0.0.0 --port 8765 run --config-file tests/testing.yaml
panoptes-config-server --host 0.0.0.0 --port 6563 run --config-file tests/testing.yaml
```

**Notes:**
- Default port is 8765
- Default port is 6563
- Server provides REST API for configuration access
- Used by POCS and other PANOPTES components

Expand Down
10 changes: 6 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@

### Added

* Config server CLI commands (`run`, `stop`, `get`, `set`) are now available under `panoptes-utils config` using Typer, harmonized with the existing `panoptes-utils` CLI. #337
* All Typer CLI scripts now use `rich.print` for styled terminal output (green for success, red for errors, bold/italic for emphasis) and `rich_markup_mode="rich"` on all `Typer` app instances. #337
* All Typer sub-apps now use `no_args_is_help=True`, showing help automatically when no subcommand is provided. #337
* Added `rich>=12.3.0` as an explicit core dependency (it was already available transitively through `typer`). #337
* Documented environment variables used by the config server and client in the `README.md`. #336

### Fixed

* Fixed test suite hang caused by `patch("panoptes.utils.cli.config.time.sleep")` leaking the global `time.sleep` patch under `pytest-cov`; patching the module-level `time` reference instead restores isolation. #337
* Unified CLI logging behavior: `DEBUG` level output is now silenced by default in all CLI tools (`panoptes-config-server` and `panoptes-utils`) and only enabled when the `--verbose` flag is provided. #336
* Config server CLI `run` command no longer exits immediately when started with `--host 0.0.0.0`; the wildcard bind address is now normalized to `localhost` for client-side connectivity checks. #336
* Config server CLI `run` command now waits up to 30 seconds (configurable via `--startup-timeout`) for the server socket to be ready before entering the monitoring loop. #336
* Startup "Bad connection" log message is now emitted at `TRACE` level (invisible by default) instead of `DEBUG` during the expected server-ready polling phase. #336
* Added null guard in `set_config_entry` before saving to disk when `_pocs_cut` is uninitialized. #336

### Changed

* Moved config server CLI into the main `panoptes.utils.cli` namespace as `panoptes-utils config`. The `panoptes-config-server` entry point has been removed; use `panoptes-utils config` instead. #337
* Migrated config server from Flask+gevent to FastAPI+uvicorn for improved performance and modern async support. #336
* Config server CLI `run` command now defaults to `--load-local` (previously `--no-load-local`) so any locally saved config overrides are applied on startup. #336

Expand Down
4 changes: 2 additions & 2 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ def pytest_configure(config) -> None: # noqa: ANN001
config_file = "tests/testing.yaml"

host = "localhost"
port = "8765"
port = "6563"

os.environ["PANOPTES_CONFIG_HOST"] = host
os.environ["PANOPTES_CONFIG_PORT"] = port

config_server(config_file, host="localhost", port=8765, load_local=False, save_local=False)
config_server(config_file, host=host, port=port, load_local=False, save_local=False)
logger.success("Config server set up")

config.addinivalue_line("markers", "plate_solve: Tests that require astrometry.net")
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies = [
"pytest-loguru",
"python-dateutil",
"requests",
"rich>=12.3.0",
"ruamel.yaml",
"typer",
]
Expand Down Expand Up @@ -80,7 +81,6 @@ images = [
]

[project.scripts]
panoptes-config-server = "panoptes.utils.config.cli:config_server_cli"
panoptes-utils = "panoptes.utils.cli.main:app"

[dependency-groups]
Expand Down
178 changes: 178 additions & 0 deletions src/panoptes/utils/cli/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import time

import typer
from loguru import logger
from rich import print
from rich.console import Console

from panoptes.utils.config import server
from panoptes.utils.config.client import get_config, server_is_running, set_config

err_console = Console(stderr=True)

app = typer.Typer(help="Manage the config server.", rich_markup_mode="rich", no_args_is_help=True)


@app.callback()
def config_callback(
ctx: typer.Context,
host: str | None = typer.Option(
None,
envvar="PANOPTES_CONFIG_HOST",
help="The config server IP address or host name. "
"Checks PANOPTES_CONFIG_HOST env var, then defaults to localhost.",
),
port: int | None = typer.Option(
None,
envvar="PANOPTES_CONFIG_PORT",
help="The config server port. Checks PANOPTES_CONFIG_PORT env var, then defaults to 6563.",
),
) -> None:
"""Manage the config server."""
ctx.ensure_object(dict)

# Distinguish between the address the server binds to and the address clients use
# to connect. When binding to 0.0.0.0 (all interfaces) or when no host is set,
# clients should connect via localhost/127.0.0.1 rather than the wildcard address.
bind_host = host
client_host = "localhost" if host in (None, "0.0.0.0") else host

# For backward compatibility, keep the "host" key pointing at the client host so
# existing commands that read ctx.obj["host"] will use a connectable address.
ctx.obj["bind_host"] = bind_host
ctx.obj["client_host"] = client_host
ctx.obj["host"] = client_host
ctx.obj["port"] = port


@app.command("run")
def run(
ctx: typer.Context,
config_file: str | None = typer.Option(
None,
envvar="PANOPTES_CONFIG_FILE",
help="The yaml config file to load.",
),
load_local: bool = typer.Option(True, help="Use the local config files when loading."),
save_local: bool = typer.Option(True, help="Save set values to the local file."),
heartbeat: int = typer.Option(2, help="Heartbeat interval in seconds."),
startup_timeout: int = typer.Option(
30, help="Seconds to wait for the server to become ready before giving up."
),
) -> None:
"""Run the config server."""
ctx.ensure_object(dict)

# Prefer the explicitly stored bind/client hosts, falling back to "host" for
# compatibility with older contexts.
bind_host = ctx.obj.get("bind_host", ctx.obj.get("host"))
client_host = ctx.obj.get("client_host", "localhost" if bind_host in (None, "0.0.0.0") else bind_host)
port = ctx.obj.get("port")

try:
server_process = server.config_server(
config_file,
host=bind_host,
port=port,
load_local=load_local,
save_local=save_local,
auto_start=False,
)
except Exception as e:
logger.error(f"Unable to start config server: {e!r}")
return

try:
print("Starting config server. [bold]Ctrl-c[/bold] to stop")
server_process.start()
print(
f"Config server started on [bold]pid={server_process.pid!r}[/bold]. "
f'Set [italic]"config_server.running=False"[/italic] or [bold]Ctrl-c[/bold] to stop'
)

# Wait for the server to become reachable before entering the monitor loop.
# uvicorn takes a moment to bind its socket after the process is forked.
logger.info(f"Waiting for config server to be ready on {client_host}:{port}")
startup_elapsed = 0.0
startup_interval = 0.5
while startup_elapsed < startup_timeout:
if server_is_running(host=client_host, port=port):
break
time.sleep(startup_interval)
startup_elapsed += startup_interval
else:
logger.error(f"Config server did not become ready within {startup_timeout}s")
server_process.terminate()
server_process.join(5)
return

logger.info("Config server is ready")

# Loop until the server signals it is no longer running.
while server_is_running(host=client_host, port=port):
time.sleep(heartbeat)

server_process.terminate()
server_process.join(30)
except KeyboardInterrupt:
logger.info(f"Config server interrupted, shutting down {server_process.pid}")
server_process.terminate()
except Exception as e:
logger.error(f"Unable to start config server {e!r}")


@app.command("stop")
def stop(ctx: typer.Context) -> None:
"""Stop the config server."""
ctx.ensure_object(dict)
host = ctx.obj.get("host")
port = ctx.obj.get("port")
logger.info(f"Shutting down config server on {host}:{port}")
set_config("config_server.running", False, host=host, port=port)


@app.command("get")
def config_get(
ctx: typer.Context,
key: str | None = typer.Argument(
None, help="Config key in dotted notation (e.g. 'location.elevation'). Returns all config if omitted."
),
default: str | None = typer.Option(None, help="The default to return if no key is found."),
parse: bool = typer.Option(True, help="Parse the result into an object."),
) -> None:
"""Get an item from the config server by key name.

Uses dotted notation (e.g. 'location.elevation'). If no key is given, returns the entire config.
"""
ctx.ensure_object(dict)
host = ctx.obj.get("host")
port = ctx.obj.get("port")
logger.debug(f"Getting config key={key!r}")
try:
config_entry = get_config(key=key, host=host, port=port, parse=parse, default=default)
except Exception as e:
logger.error(f"Error while trying to get config: {e!r}")
err_console.print(f"[red]Error while trying to get config: {e!r}[/red]")
else:
logger.debug(f"Config server response: config_entry={config_entry!r}")
print(config_entry)


@app.command("set")
def config_set(
ctx: typer.Context,
key: str = typer.Argument(..., help="Config key in dotted notation."),
new_value: str = typer.Argument(..., help="New value to set."),
parse: bool = typer.Option(True, help="Parse the result into an object."),
) -> None:
"""Set an item in the config server."""
ctx.ensure_object(dict)
host = ctx.obj.get("host")
port = ctx.obj.get("port")
logger.debug(f"Setting config key={key!r} new_value={new_value!r} on {host}:{port}")
config_entry = set_config(key, new_value, host=host, port=port, parse=parse)
print(config_entry)


if __name__ == "__main__":
app()
35 changes: 18 additions & 17 deletions src/panoptes/utils/cli/image.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
from pathlib import Path

import typer
from rich import print
from watchfiles import Change, watch

from panoptes.utils import error
from panoptes.utils.images import cr2
from panoptes.utils.images import fits as fits_utils

app = typer.Typer()
app = typer.Typer(rich_markup_mode="rich", no_args_is_help=True)

cr2_app = typer.Typer()
cr2_app = typer.Typer(rich_markup_mode="rich", no_args_is_help=True)
app.add_typer(cr2_app, name="cr2")

fits_app = typer.Typer()
fits_app = typer.Typer(rich_markup_mode="rich", no_args_is_help=True)
app.add_typer(fits_app, name="fits")


Expand All @@ -37,7 +38,7 @@ def watch_directory(
* Plate-solve FITS files.

"""
typer.secho(f"Watching {path}", fg="green")
print(f"[green]Watching {path}[/green]")
for changes in watch(path):
for change in changes:
change_type = change[0]
Expand All @@ -46,28 +47,28 @@ def watch_directory(
if change_type == Change.added:
if change_path.suffix == ".cr2":
if to_jpg:
typer.secho(f"Converting {change_path} to JPG")
print(f"Converting {change_path} to JPG")
try:
cr2_to_jpg(
change_path,
overwrite=overwrite,
remove_cr2=remove_cr2 and not to_fits,
)
except Exception as e:
typer.secho(f"Error converting {change_path} to JPG: {e}", fg="red")
print(f"[red]Error converting {change_path} to JPG: {e}[/red]")
if to_fits:
typer.secho(f"Converting {change_path} to FITS")
print(f"Converting {change_path} to FITS")
try:
cr2_to_fits(change_path, remove_cr2=remove_cr2, overwrite=overwrite)
except Exception as e:
typer.secho(f"Error converting {change_path} to FITS: {e}", fg="red")
print(f"[red]Error converting {change_path} to FITS: {e}[/red]")
if change_path.suffix == ".fits":
if solve:
typer.secho(f"Solving {change_path}")
print(f"Solving {change_path}")
try:
solve_fits(change_path)
except Exception as e:
typer.secho(f"Error solving {change_path}: {e}", fg="red")
print(f"[red]Error solving {change_path}: {e}[/red]")


@cr2_app.command("to-jpg")
Expand All @@ -87,7 +88,7 @@ def cr2_to_jpg(
overwrite (bool): Overwrite existing JPG file.
remove_cr2 (bool): Remove the CR2 file after conversion.
"""
typer.secho(f"Converting {cr2_fname} to JPG", fg="green")
print(f"[green]Converting {cr2_fname} to JPG[/green]")
jpg_fname = cr2.cr2_to_jpg(
cr2_fname,
jpg_fname=jpg_fname,
Expand All @@ -97,7 +98,7 @@ def cr2_to_jpg(
)

if jpg_fname.exists():
typer.secho(f"Wrote {jpg_fname}", fg="green")
print(f"[green]Wrote {jpg_fname}[/green]")

return jpg_fname

Expand All @@ -110,27 +111,27 @@ def cr2_to_fits(
remove_cr2: bool = False,
) -> Path:
"""Convert a CR2 image to a FITS, return the new path name."""
typer.secho(f"Converting {cr2_fname} to FITS", fg="green")
print(f"[green]Converting {cr2_fname} to FITS[/green]")
fits_fn = cr2.cr2_to_fits(cr2_fname, fits_fname=fits_fname, overwrite=overwrite, remove_cr2=remove_cr2)

if fits_fname is not None:
typer.secho(f"FITS file available at {fits_fn}", fg="green")
print(f"[green]FITS file available at {fits_fn}[/green]")
return Path(fits_fn)


@fits_app.command("solve")
def solve_fits(fits_fname: Path, **kwargs) -> Path | None: # noqa: ANN003
"""Plate-solve a FITS file."""
typer.secho(f"Solving {fits_fname}", fg="green")
print(f"[green]Solving {fits_fname}[/green]")
try:
solve_info = fits_utils.get_solve_field(fits_fname, **kwargs)
except error.InvalidSystemCommand as e:
typer.secho(f"Error while trying to solve {fits_fname}: {e!r}", fg="red")
print(f"[red]Error while trying to solve {fits_fname}: {e!r}[/red]")
return None

solve_fn = solve_info["solved_fits_file"]

typer.secho(f"Plate-solved file available at {solve_fn}", fg="green")
print(f"[green]Plate-solved file available at {solve_fn}[/green]")
return Path(solve_fn)


Expand Down
5 changes: 3 additions & 2 deletions src/panoptes/utils/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import typer
from loguru import logger

from panoptes.utils.cli import image
from panoptes.utils.cli import config, image

app = typer.Typer()
app = typer.Typer(rich_markup_mode="rich", no_args_is_help=True)


@app.callback()
Expand All @@ -23,6 +23,7 @@ def main(verbose: bool = False):
logger.add(sys.stderr, level="INFO")


app.add_typer(config.app, name="config", help="Manage the config server.")
app.add_typer(image.app, name="image", help="Process an image.")

if __name__ == "__main__":
Expand Down
Loading
Loading