nostr-dev 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nostr_dev/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Developer tooling for Nostr workflows."""
2
+
3
+ __version__ = "1.0.0"
nostr_dev/cli.py ADDED
@@ -0,0 +1,62 @@
1
+ """Command-line interface for nostr-dev."""
2
+
3
+ import typer
4
+
5
+ from nostr_dev import __version__
6
+ from nostr_dev.commands.common import handle_command_errors, set_debug_enabled
7
+ from nostr_dev.commands.events import events
8
+ from nostr_dev.commands.github import github_app
9
+ from nostr_dev.commands.keys import import_command, keygen, whoami
10
+ from nostr_dev.commands.publish import publish
11
+ from nostr_dev.commands.relay import relay_app
12
+ from nostr_dev.logging import setup_logging
13
+
14
+ app = typer.Typer(
15
+ add_completion=False,
16
+ help="Developer tooling for Nostr workflows.",
17
+ no_args_is_help=True,
18
+ pretty_exceptions_show_locals=False,
19
+ )
20
+
21
+
22
+ def version_callback(value: bool) -> None:
23
+ """Print the application version and exit."""
24
+ if value:
25
+ typer.echo(f"nostr-dev {__version__}")
26
+ raise typer.Exit
27
+
28
+
29
+ @app.callback()
30
+ @handle_command_errors
31
+ def main(
32
+ version: bool = typer.Option(
33
+ False,
34
+ "--version",
35
+ callback=version_callback,
36
+ help="Show the application version and exit.",
37
+ is_eager=True,
38
+ ),
39
+ verbose: bool = typer.Option(
40
+ False,
41
+ "--verbose",
42
+ "-v",
43
+ help="Show more detailed progress output.",
44
+ ),
45
+ debug: bool = typer.Option(
46
+ False,
47
+ "--debug",
48
+ help="Show debug logs and full tracebacks for unexpected errors.",
49
+ ),
50
+ ) -> None:
51
+ """Developer tooling for Nostr workflows."""
52
+ set_debug_enabled(debug)
53
+ setup_logging(verbose=verbose, debug=debug)
54
+
55
+
56
+ app.command("keygen")(keygen)
57
+ app.command("import")(import_command)
58
+ app.command("whoami")(whoami)
59
+ app.command("publish")(publish)
60
+ app.command("events")(events)
61
+ app.add_typer(relay_app, name="relay")
62
+ app.add_typer(github_app, name="github")
@@ -0,0 +1 @@
1
+ """CLI command modules."""
@@ -0,0 +1,80 @@
1
+ """Shared CLI command helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator
6
+ from contextlib import contextmanager
7
+ from functools import wraps
8
+
9
+ import structlog
10
+ import typer
11
+ from rich.console import Console
12
+ from sqlalchemy.exc import SQLAlchemyError
13
+ from sqlmodel import Session, SQLModel
14
+
15
+ from nostr_dev.config import load_config
16
+ from nostr_dev.db import create_db_engine, get_session
17
+ from nostr_dev.exceptions import ConfigError, NostrDevError
18
+
19
+ console = Console()
20
+ err_console = Console(stderr=True)
21
+ debug_enabled = False
22
+
23
+
24
+ def set_debug_enabled(value: bool) -> None:
25
+ """Set whether unexpected command exceptions should be re-raised."""
26
+ global debug_enabled
27
+ debug_enabled = value
28
+
29
+
30
+ def handle_command_errors[**P, R](command: Callable[P, R]) -> Callable[P, R]:
31
+ """Print clean CLI errors for expected failures and short errors otherwise."""
32
+
33
+ @wraps(command)
34
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
35
+ try:
36
+ return command(*args, **kwargs)
37
+ except NostrDevError as exc:
38
+ err_console.print(str(exc), style="red")
39
+ raise typer.Exit(1) from exc
40
+ except typer.Exit:
41
+ raise
42
+ except Exception as exc:
43
+ structlog.get_logger(__name__).exception("unexpected_cli_error")
44
+ if debug_enabled:
45
+ raise
46
+ err_console.print(
47
+ f"Unexpected error: {exc}. Run with --debug for full traceback.",
48
+ style="red",
49
+ )
50
+ raise typer.Exit(1) from exc
51
+
52
+ return wrapper
53
+
54
+
55
+ @contextmanager
56
+ def managed_session() -> Iterator[Session]:
57
+ """Yield a command session after ensuring the local database exists."""
58
+ ensure_database()
59
+ try:
60
+ with get_session() as session:
61
+ yield session
62
+ except SQLAlchemyError as exc:
63
+ raise ConfigError("Database operation failed.") from exc
64
+
65
+
66
+ def ensure_database() -> None:
67
+ """Ensure database tables exist for command execution."""
68
+ try:
69
+ config = load_config()
70
+ engine = create_db_engine(config)
71
+ try:
72
+ SQLModel.metadata.create_all(engine)
73
+ finally:
74
+ engine.dispose()
75
+ except NostrDevError:
76
+ raise
77
+ except SQLAlchemyError as exc:
78
+ raise ConfigError("Database setup failed.") from exc
79
+ except OSError as exc:
80
+ raise ConfigError("Could not prepare nostr-dev data directory.") from exc
@@ -0,0 +1,86 @@
1
+ """Query Nostr events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import UTC, datetime
7
+
8
+ import typer
9
+ from nostr_sdk import Event, Filter
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+ from sqlmodel import Session, col, select
13
+
14
+ from nostr_dev.commands.common import handle_command_errors, managed_session
15
+ from nostr_dev.exceptions import ConfigError
16
+ from nostr_dev.models import Relay
17
+ from nostr_dev.services.nostr import NostrEventSummary, build_subscription_filter, summarize_event
18
+ from nostr_dev.services.relay import RelayPool
19
+
20
+ console = Console()
21
+ _managed_session = managed_session
22
+
23
+
24
+ @handle_command_errors
25
+ def events(
26
+ author: str | None = typer.Option(None, "--author", help="Filter by author npub."),
27
+ limit: int = typer.Option(20, "--limit", min=1, help="Maximum number of events to show."),
28
+ kind: int | None = typer.Option(None, "--kind", min=0, help="Filter by event kind."),
29
+ timeout_seconds: float = typer.Option(
30
+ 8.0,
31
+ "--timeout",
32
+ min=0.1,
33
+ help="Subscription timeout in seconds.",
34
+ ),
35
+ ) -> None:
36
+ """Query active relays for Nostr events."""
37
+ with _managed_session() as session:
38
+ relay_urls = _active_relay_urls(session)
39
+ if not relay_urls:
40
+ raise ConfigError("No active relays found. Run `nostr-dev relay add <url>`.")
41
+
42
+ subscription_filter = build_subscription_filter(author=author, kind=kind, limit=limit)
43
+ sdk_events = asyncio.run(_subscribe_events(subscription_filter, relay_urls, timeout_seconds))
44
+ summaries = [summarize_event(event) for event in sdk_events[:limit]]
45
+ _print_events(summaries)
46
+
47
+
48
+ async def _subscribe_events(
49
+ subscription_filter: Filter,
50
+ relay_urls: list[str],
51
+ timeout_seconds: float,
52
+ ) -> list[Event]:
53
+ pool = RelayPool(relay_urls)
54
+ return await pool.subscribe(subscription_filter, relay_urls, timeout_seconds)
55
+
56
+
57
+ def _print_events(event_summaries: list[NostrEventSummary]) -> None:
58
+ table = Table(title="Nostr Events")
59
+ table.add_column("Author", style="cyan")
60
+ table.add_column("Kind")
61
+ table.add_column("Created")
62
+ table.add_column("Content")
63
+ for event in event_summaries:
64
+ table.add_row(
65
+ event.author,
66
+ str(event.kind),
67
+ _format_timestamp(event.created_at),
68
+ _preview(event.content),
69
+ )
70
+ console.print(table)
71
+
72
+
73
+ def _preview(content: str, max_length: int = 80) -> str:
74
+ normalized = " ".join(content.split())
75
+ if len(normalized) <= max_length:
76
+ return normalized
77
+ return f"{normalized[: max_length - 1]}..."
78
+
79
+
80
+ def _format_timestamp(timestamp: int) -> str:
81
+ return datetime.fromtimestamp(timestamp, tz=UTC).isoformat(timespec="seconds")
82
+
83
+
84
+ def _active_relay_urls(session: Session) -> list[str]:
85
+ statement = select(Relay).where(Relay.is_active).order_by(col(Relay.url).asc())
86
+ return [relay.url for relay in session.exec(statement).all()]