Skip to content
Closed
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
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies = [
"requests>=2.32.4",
"textual>=3.3.0",
"pydantic>=2.7.1",
"prompt-toolkit>=3.0.51",
]

[project.urls]
Expand All @@ -44,7 +45,7 @@ dependencies = [
"Documentation" = "https://github.com/simedw/spegel#readme"

[project.scripts]
spegel = "spegel.main:main"
spegel = "spegel._internal.cli:main"

[project.optional-dependencies]
dev = [
Expand Down
11 changes: 4 additions & 7 deletions src/spegel/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
"""Spegel – Reflect the web through AI (package entry)."""

from importlib.metadata import version, PackageNotFoundError
from ._internal.debug import _get_version

try:
__version__: str = version("spegel")
except PackageNotFoundError: # pragma: no cover
__version__ = "0.0.0-dev"
__version__: str = _get_version()

from .main import Spegel as _SpegelApp, main # noqa: F401
from .main import Spegel as _SpegelApp # noqa: F401

__all__ = ["__version__", "_SpegelApp", "main"]
__all__ = ["__version__", "_SpegelApp"]
8 changes: 6 additions & 2 deletions src/spegel/__main__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Run Spegel as a module: `python -m spegel`"""

from .main import main
import sys

from ._internal.cli import main as cli_main

if __name__ == "__main__":
main()
sys.exit(
cli_main(sys.argv[1:])
) # Pass command-line arguments to the CLI main function
Empty file.
90 changes: 90 additions & 0 deletions src/spegel/_internal/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Why does this file exist, and why not put this in `__main__`?
#
# You might be tempted to import things from `__main__` later,
# but that will cause problems: the code will get executed twice:
#
# - When you run `python -m spegel` python will execute
# `__main__.py` as a script. That means there won't be any
# `spegel.__main__` in `sys.modules`.
# - When you import `__main__` it will get executed again (as a module) because
# there's no `spegel.__main__` in `sys.modules`.
from __future__ import annotations

from argparse import Action, ArgumentParser, Namespace
import sys
from typing import Any

from spegel._internal import debug
from spegel.main import Spegel


class _DebugInfo(Action):
def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
super().__init__(nargs=nargs, **kwargs)

def __call__(self, *_: Any, **__: Any) -> None:
print(debug._get_debug_info())
sys.exit(0)


class _About(Action):
def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
super().__init__(nargs=nargs, **kwargs)

def __call__(self, *_: Any, **__: Any) -> None:
print(debug._get_package_info())
sys.exit(0)


def get_parser() -> ArgumentParser:
name: str = debug._get_name()
version: str = f"{name} v{debug._get_version()}"
parser = ArgumentParser(
description=name.capitalize(), prog=name, exit_on_error=False
)
parser.add_argument("-V", "--version", action="version", version=version)
parser.add_argument(
"--about", action=_About, help="Print information about the package"
)
parser.add_argument(
"--debug_info", action=_DebugInfo, help="Print debug information"
)
parser.add_argument("url", nargs="?", help="URL to open immediately on launch")
return parser


def main(args: list[str] | None = None) -> int:
"""Main entry point for the CLI.

This function is called when the CLI is executed. It can be used to
initialize the CLI, parse arguments, and execute commands.

Args:
args (list[str] | None): A list of command-line arguments. If None, uses sys.argv[1:].

Returns:
int: Exit code of the CLI execution. 0 for success, non-zero for failure.
"""
if args is None:
args = sys.argv[1:]
try:
parser: ArgumentParser = get_parser()
opts: Namespace = parser.parse_args(args)
initial_url: str | None = opts.url

if initial_url is not None and not initial_url.startswith(
("http://", "https://")
):
# Auto-prepend https if scheme is missing
initial_url = f"https://{initial_url}"

app = Spegel(initial_url=initial_url)
app.run()
except Exception as e:
print(f"Error initializing {debug._get_name()}: {e}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
main()
159 changes: 159 additions & 0 deletions src/spegel/_internal/debug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from __future__ import annotations

from dataclasses import dataclass
import importlib.metadata
from importlib.metadata import PackageNotFoundError, metadata, version
import os
import platform
import sys

__PACKAGE_NAME__ = "spegel"


@dataclass
class _Package:
"""Dataclass to store package information."""

name: str = __PACKAGE_NAME__
"""Package name."""
version: str = "0.0.0-dev"
"""Package version."""
description: str = "No description available."
"""Package description."""

def __str__(self) -> str:
"""String representation of the package information."""
return f"{self.name} v{self.version}: {self.description}"


@dataclass
class _Variable:
"""Dataclass describing an environment variable."""

name: str
"""Variable name."""
value: str
"""Variable value."""


@dataclass
class _Environment:
"""Dataclass to store environment information."""

interpreter_name: str
"""Python interpreter name."""
interpreter_version: str
"""Python interpreter version."""
interpreter_path: str
"""Path to Python executable."""
platform: str
"""Operating System."""
packages: list[_Package]
"""Installed packages."""
variables: list[_Variable]
"""Environment variables."""

def __str__(self) -> str:
"""String representation of the environment information."""
return (
f"Python {self.interpreter_name} {self.interpreter_version} "
f"({self.interpreter_path}) on {self.platform}\n"
f"Packages:\n{', '.join(str(pkg) for pkg in self.packages)}\n"
f"Variables:\n{', '.join(f'{var.name}={var.value}' for var in self.variables)}"
)


def _interpreter_name_version() -> tuple[str, str]:
if hasattr(sys, "implementation"):
impl: sys._version_info = sys.implementation.version
version = f"{impl.major}.{impl.minor}.{impl.micro}"
kind = impl.releaselevel
if kind != "final":
version += kind[0] + str(impl.serial)
return sys.implementation.name, version
return "", "0.0.0"


def _get_package_info(dist: str = __PACKAGE_NAME__) -> _Package:
try:
return _Package(
name=dist,
version=version(dist),
description=metadata(dist)["Summary"],
)
except PackageNotFoundError:
return _Package(name=dist)


def _get_name(dist: str = __PACKAGE_NAME__) -> str:
"""Get name of the given distribution.

Parameters:
dist: A distribution name.

Returns:
A package name.
"""
return _get_package_info(dist).name


def _get_version(dist: str = __PACKAGE_NAME__) -> str:
"""Get version of the given distribution.

Parameters:
dist: A distribution name.

Returns:
A version number.
"""
return _get_package_info(dist).version


def _get_description(dist: str = __PACKAGE_NAME__) -> str:
"""Get description of the given distribution.

Parameters:
dist: A distribution name.

Returns:
A description string.
"""
return _get_package_info(dist).description


def _get_debug_info() -> _Environment:
"""Get debug/environment information.

Returns:
Environment information.
"""
py_name, py_version = _interpreter_name_version()
packages: list[str] = [__PACKAGE_NAME__]
variables: list[str] = [
"PYTHONPATH",
*[
var
for var in os.environ
if var.startswith(__PACKAGE_NAME__.replace("-", "_"))
],
]
return _Environment(
interpreter_name=py_name,
interpreter_version=py_version,
interpreter_path=sys.executable,
platform=platform.platform(),
variables=[_Variable(var, val) for var in variables if (val := os.getenv(var))],
packages=[_Package(pkg, _get_version(pkg)) for pkg in packages],
)


def _get_installed_packages() -> list[_Package]:
"""Get all installed packages in current environment"""
packages = []
for dist in importlib.metadata.distributions():
packages.append({"name": dist.metadata["Name"], "version": dist.version})
return packages


if __name__ == "__main__":
print(_get_debug_info())
33 changes: 16 additions & 17 deletions src/spegel/config.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
from __future__ import annotations

from pathlib import Path
from typing import List, Dict, Any

import tomllib
from pydantic import BaseModel, Field, model_validator


"""Configuration handling for Spegel.

This module is responsible for:
Expand All @@ -15,6 +6,14 @@
• Providing fallback defaults so the app can run with zero user config.
"""

from __future__ import annotations

import tomllib
from pathlib import Path
from typing import Any, Self

from pydantic import BaseModel, Field, model_validator

__all__ = [
"View",
"AI",
Expand All @@ -39,10 +38,10 @@ class View(BaseModel):
model: str = "" # Optional model override for this view

@model_validator(mode="after")
def validate_hotkey(cls, values): # type: ignore[override]
if len(values.hotkey) != 1:
def validate_hotkey(self) -> Self:
if len(self.hotkey) != 1:
raise ValueError("Hotkey must be a single character")
return values
return self


class AI(BaseModel):
Expand All @@ -65,9 +64,9 @@ class FullConfig(BaseModel):
settings: Settings = Settings()
ai: AI = AI()
ui: UI = UI()
views: List[View] = Field(default_factory=list)
views: list[View] = Field(default_factory=list)

def view_map(self) -> Dict[str, View]:
def view_map(self) -> dict[str, View]:
"""Return a mapping of view_id → View for quick lookup."""
return {v.id: v for v in self.views if v.enabled}

Expand All @@ -76,7 +75,7 @@ def view_map(self) -> Dict[str, View]:
# Defaults
# --------------------------------------------------------------------------------------

DEFAULT_CONFIG_DICT: Dict[str, Any] = {
DEFAULT_CONFIG_DICT: dict[str, Any] = {
"settings": {
"default_view": "terminal",
"max_history": 50,
Expand Down Expand Up @@ -114,7 +113,7 @@ def view_map(self) -> Dict[str, View]:
}


def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge two dicts (override wins)."""
result = base.copy()
for key, value in override.items():
Expand Down Expand Up @@ -143,7 +142,7 @@ def load_config() -> FullConfig:
Path.home() / ".config" / "spegel" / "config.toml",
]

merged: Dict[str, Any] = DEFAULT_CONFIG_DICT
merged: dict[str, Any] = DEFAULT_CONFIG_DICT

# Only load the first config file found, not all of them
for path in config_paths:
Expand Down
Loading