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
9 changes: 9 additions & 0 deletions docs/source/changelog/3.0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

*Note: This is the current development branch and is not yet released.*

## What's New?

Minor cleanups and documentation fixes.

## Bug Fixes

- Fixed issue during TUI app initialization where debug logs would be discarded
and warnings would fail to display correctly.

## Documentation

- Docs: Fix minor grammar and render nits
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "exosphere-cli"
version = "3.0.1.dev0"
version = "3.0.1.dev2"
description = "CLI/TUI driven patch reporting for remote Unix-like systems."
readme = "README.md"
authors = [
Expand Down
13 changes: 6 additions & 7 deletions scripts/generate_example_reports.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/usr/bin/env python3
"""
Example Reports Generation Script

Expand All @@ -19,7 +18,7 @@
I'm sorry.
"""

from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

Expand All @@ -46,7 +45,7 @@ def create_sample_hosts() -> list[Host]:
web_server.package_manager = "apt"
web_server.supported = True
web_server.online = True
web_server.last_refresh = datetime.now(tz=timezone.utc) - timedelta(hours=2)
web_server.last_refresh = datetime.now(tz=UTC) - timedelta(hours=2)
web_server.needs_reboot = False
web_server.updates = [
Update("apache2", "2.4.52-1ubuntu4.6", "2.4.52-1ubuntu4.7", True, "security"),
Expand All @@ -68,7 +67,7 @@ def create_sample_hosts() -> list[Host]:
db_server.package_manager = "apt"
db_server.supported = True
db_server.online = True
db_server.last_refresh = datetime.now(tz=timezone.utc) - timedelta(hours=1)
db_server.last_refresh = datetime.now(tz=UTC) - timedelta(hours=1)
db_server.needs_reboot = True
db_server.updates = [
Update("postgresql-14", "14.9-0+deb12u1", "14.10-0+deb12u1", True, "security"),
Expand All @@ -86,7 +85,7 @@ def create_sample_hosts() -> list[Host]:
admin_server.package_manager = "pkg"
admin_server.supported = True
admin_server.online = True
admin_server.last_refresh = datetime.now(tz=timezone.utc) - timedelta(hours=3)
admin_server.last_refresh = datetime.now(tz=UTC) - timedelta(hours=3)
admin_server.needs_reboot = False
admin_server.updates = [
Update("en-freebsd-doc", "20250814,1", "20250920,1", False, "FreeBSD"),
Expand All @@ -102,7 +101,7 @@ def create_sample_hosts() -> list[Host]:
lb_server.package_manager = "dnf"
lb_server.supported = True
lb_server.online = True
lb_server.last_refresh = datetime.now(tz=timezone.utc) - timedelta(minutes=30)
lb_server.last_refresh = datetime.now(tz=UTC) - timedelta(minutes=30)
lb_server.needs_reboot = False
lb_server.updates = [] # No updates available
hosts.append(lb_server)
Expand All @@ -119,7 +118,7 @@ def create_sample_hosts() -> list[Host]:
dev_server.package_manager = "apt"
dev_server.supported = True
dev_server.online = False # Currently offline
dev_server.last_refresh = datetime.now(tz=timezone.utc) - timedelta(days=3)
dev_server.last_refresh = datetime.now(tz=UTC) - timedelta(days=3)
dev_server.updates = [
# Has some cached updates from last time it was online
Update("git", "1:2.34.1-1ubuntu1.9", "1:2.34.1-1ubuntu1.10", False, "updates"),
Expand Down
25 changes: 15 additions & 10 deletions scripts/release_preflight.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/usr/bin/env python3
"""
Release Preflight Checks

Expand All @@ -11,7 +10,7 @@
a neat manual checklist of all the stuff the maintainer still has to do
by hand like a caveman banging rocks together.

It is especially useful for ensure the docs and changelog were not
It is especially useful for ensuring the docs and changelog were not
forgotten, and avoiding small details that generate the "oh fuck"
moments 3 hours after a release while I'm on the couch.

Expand Down Expand Up @@ -62,19 +61,25 @@ class GateOutcome(Enum):
# A dirty human still has to do these at this point in time.
# They will be printed at the end of the preflight if it is ready.
MANUAL_STEPS = [
"Do a final compat pass against the LATEST dependency versions - pipx/uv "
"tool installs do not pin, so `uv lock --upgrade` then `poe test` now, not "
"after release.",
(
"Do a final compat pass against the LATEST dependency versions - pipx/uv "
"tool installs do not pin, so `uv lock --upgrade` then `poe test` now, not "
"after release."
),
"Confirm the documentation covers any new features or options.",
"Create a [red]SIGNED[/red] tag `git tag -s vX.Y.Z`.",
"[red]Rerun the preflight[/red]",
"Push the tag to origin",
"Once CI is green on the tag, draft the GitHub release: paste "
"changelog/<version>.md as the body and attach screenshots/video.",
(
"Once CI is green on the tag, draft the GitHub release: paste "
"changelog/<version>.md as the body and attach screenshots/video."
),
"Once satisfied, publish the GitHub Release.",
"Check build actions for release",
"Approve the PyPI publish in the GitHub environment (or wait out the "
"'oh fuck' timer and confirm).",
(
"Approve the PyPI publish in the GitHub environment (or wait out the "
"'oh fuck' timer and confirm)."
),
"Check the release on PyPI.",
"Check the live documentation on Read the Docs.",
"Do a test upgrade on a real system.",
Expand All @@ -84,7 +89,7 @@ class GateOutcome(Enum):

def _run(*args: str) -> subprocess.CompletedProcess[str]:
"""Run a command at the repo root, capturing output."""
return subprocess.run(args, cwd=ROOT, capture_output=True, text=True)
return subprocess.run(args, cwd=ROOT, capture_output=True, text=True, check=False)


def project_version() -> str:
Expand Down
2 changes: 1 addition & 1 deletion src/exosphere/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ def edit(

try:
validate_config(target)
except Exception as e:
except (ValueError, OSError) as e:
err_console.print(f"[red]Configuration is invalid:[/red]\n{e}")
if Confirm.ask("Re-open editor to fix?", default=True):
continue
Expand Down
26 changes: 17 additions & 9 deletions src/exosphere/commands/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,15 @@ def discover(host: HostArg, /) -> int:
progress.add_task(f"Discovering platform for '{host.name}'", total=None)
try:
host.discover()
except Exception as e:
except Exception as e: # noqa: BLE001
# Broad on purpose: providers hand the Connection to fabric
# directly, so paramiko/invoke errors (SSHException,
# NoValidConnectionsError, AuthFailure) leak past
# DataRefreshError on a host that dies mid-operation.
# FIXME: This handling should be moved at the Host boundary
progress.console.print(
Panel.fit(
f"{str(e)}",
f"{e!s}",
title="[red]Error[/red]",
style="red",
title_align="left",
Expand Down Expand Up @@ -277,17 +282,18 @@ def refresh(
discover
Also refresh platform information
"""
with Progress(transient=True, *SPINNER_ARGS) as progress:
with Progress(*SPINNER_ARGS, transient=True) as progress:
if discover:
task = progress.add_task(
f"Refreshing platform information for '{host.name}'", total=None
)
try:
host.discover()
except Exception as e:
except Exception as e: # noqa: BLE001
# Broad on purpose, see discover() above.
progress.console.print(
Panel.fit(
f"{str(e)}",
f"{e!s}",
title="[red]Error[/red]",
style="red",
title_align="left",
Expand All @@ -304,10 +310,11 @@ def refresh(
)
try:
host.sync_repos()
except Exception as e:
except Exception as e: # noqa: BLE001
# Broad on purpose, see discover() above.
progress.console.print(
Panel.fit(
f"{str(e)}",
f"{e!s}",
title="[red]Error[/red]",
style="red",
title_align="left",
Expand All @@ -321,10 +328,11 @@ def refresh(
task = progress.add_task(f"Refreshing updates for '{host.name}'", total=None)
try:
host.refresh_updates()
except Exception as e:
except Exception as e: # noqa: BLE001
# Broad on purpose, see discover() above.
progress.console.print(
Panel.fit(
f"{str(e)}",
f"{e!s}",
title="[red]Error[/red]",
style="red",
title_align="left",
Expand Down
41 changes: 22 additions & 19 deletions src/exosphere/commands/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
save_inventory_state,
)
from exosphere.inventory import FilterMode, Inventory, SortField
from exosphere.objects import HostOperation
from exosphere.objects import Host, HostOperation

# Constants for display
ERROR_STYLE = {
Expand Down Expand Up @@ -274,7 +274,7 @@ def ping(*names: HostArg) -> int:
error_count += 1
if exc:
progress.console.print(
f" Host [bold]{host.name}[/bold]: [bold red]ERROR[/bold red] - {str(exc)}",
f" Host [bold]{host.name}[/bold]: [bold red]ERROR[/bold red] - {exc!s}",
)
else:
progress.console.print(
Expand Down Expand Up @@ -420,12 +420,22 @@ def status(
caption_justify="right",
)

def get_platform_value(host: Host, value: str | None) -> str:
"""
Retrieve display value for host platform.
Will return appropriate placeholders as fallback.
"""
if value:
return value
elif not host.supported:
return "[dim](unsupported)[/dim]"
else:
return "[dim](undiscovered)[/dim]"

for host in hosts:
# Prepare some rendering data for suffixes and placeholders
# Prepare some rendering data for suffixes
stale_suffix = " [dim]*[/dim]" if host.is_stale else ""
reboot_suffix = " [red]![/red]" if host.needs_reboot else ""
undiscovered_status = "[dim](undiscovered)[/dim]"
unsupported_status = "[dim](unsupported)[/dim]"
empty_placeholder = "[dim]—[/dim]"

# Prepare table row data
Expand All @@ -446,22 +456,12 @@ def status(
"[bold green]Online[/bold green]" if host.online else "[red]Offline[/red]"
) + reboot_suffix

# Helper function to get platform info with appropriate
# handling for unsupported and undiscovered hosts
def get_platform_info(value):
if value:
return value
elif not host.supported:
return unsupported_status
else:
return undiscovered_status

# Construct table row for host
row = [
host.name,
get_platform_info(host.os),
get_platform_info(host.flavor),
get_platform_info(host.version),
get_platform_value(host, host.os),
get_platform_value(host, host.flavor),
get_platform_value(host, host.version),
updates,
security_updates,
online_status,
Expand Down Expand Up @@ -537,7 +537,10 @@ def clear(

try:
inventory.clear_state()
except Exception as e:
except Exception as e: # noqa: BLE001
# clear_state wraps cache errors as RuntimeError, but also
# re-runs init_all() afterwards, which reopens the full config
# and host construction surfaces, so this is broad on purpose.
err_console.print(
Panel.fit(
f"[bold red]Error clearing inventory state:[/bold red] {e}",
Expand Down
4 changes: 2 additions & 2 deletions src/exosphere/commands/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ def generate(
if output:
try:
output.write_text(content, encoding="utf-8")
except Exception as e:
except (OSError, UnicodeEncodeError) as e:
err_console.print(f"[red]Failed to write to {output}: {e}[/red]")
return 2 # Application error

Expand Down Expand Up @@ -267,7 +267,7 @@ def schema(
if output:
try:
output.write_text(content, encoding="utf-8")
except Exception as e:
except (OSError, UnicodeEncodeError) as e:
err_console.print(f"[red]Failed to write to {output}: {e}[/red]")
return 2 # Application error

Expand Down
7 changes: 4 additions & 3 deletions src/exosphere/commands/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ def run_task_with_progress(
else:
skipped = []

with Progress(transient=transient, *progress_args) as progress:
with Progress(*progress_args, transient=transient) as progress:
task = progress.add_task(task_description, total=len(hosts))

# Surface skipped hosts up front, if any. It is easier to do
Expand All @@ -381,7 +381,7 @@ def run_task_with_progress(
if exc:
if immediate_error_display:
progress.console.print(
f"{operation.label}: [red]{str(exc)}[/red]",
f"{operation.label}: [red]{exc!s}[/red]",
)

if collect_errors:
Expand Down Expand Up @@ -418,7 +418,8 @@ def save_inventory_state() -> None:
try:
inventory.save_state()
progress.stop_task(task)
except Exception as e:
except Exception as e: # noqa: BLE001
# Persistence failure is terminal for the caller either way
logger.error("Error saving inventory: %s", e)
progress.stop_task(task)
progress.console.print(
Expand Down
5 changes: 4 additions & 1 deletion src/exosphere/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ def check(
f"[red]Error:[/red] Unexpected response from PyPI API (missing key: {e})"
)
return 2 # Application error
except Exception as e:
except Exception as e: # noqa: BLE001
# Tail catch for any unexpected failures, covers json garbage,
# network issues, or anything else under the sun.
# "Is there an update" shouldn't just raise at runtime.
err_console.print(f"[red]Error:[/red] Failed to check for updates: {e}")
return 2 # Application error
Loading