diff --git a/.gitignore b/.gitignore index c058b4c..d6fe17e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,8 @@ dist/ # IDE / editor .vscode/ .idea/ +.codex # OS files .DS_Store -Thumbs.db +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md index 53a3d2a..1451d8b 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,37 @@ pip install requests textual python run.py ``` +Or install the command locally: + +```bash +pip install -e . +``` + +Launch the Textual UI: + +```bash +checkaddy +``` + +Check an address directly in the terminal: + +```bash +checkaddy
--coin BTC +``` + +If the address format maps to exactly one supported network, `--coin` can be omitted: + +```bash +checkaddy
+``` + +For EVM addresses and legacy formats that can belong to multiple networks, pass the network explicitly: + +```bash +checkaddy 0x0000000000000000000000000000000000000000 --coin ETH +checkaddy 0x0000000000000000000000000000000000000000 --coin MATIC --json +``` + ## Notes - Only public wallet addresses are supported. diff --git a/checkaddy_app/__init__.py b/checkaddy_app/__init__.py index 5ab6b2d..3c91a99 100644 --- a/checkaddy_app/__init__.py +++ b/checkaddy_app/__init__.py @@ -1,3 +1,11 @@ -from .app import CheckAddyApp +from __future__ import annotations __all__ = ["CheckAddyApp"] + + +def __getattr__(name: str) -> object: + if name == "CheckAddyApp": + from .app import CheckAddyApp + + return CheckAddyApp + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/checkaddy_app/cli.py b/checkaddy_app/cli.py new file mode 100644 index 0000000..e0a668c --- /dev/null +++ b/checkaddy_app/cli.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Sequence + +from .api import ApiClient +from .constants import COIN_DISPLAY_SYMBOL, COIN_OPTIONS, POLYGON +from .formatters import format_amount_display, format_validation_badge +from .lookup import build_lookup_result +from .models import LookupResult +from .validators import validate_address + + +COIN_ALIASES = { + "MATIC": POLYGON, + "POLYGON": POLYGON, + "BNB": "BSC", +} +SUPPORTED_COINS = tuple(coin for coin, _ in COIN_OPTIONS) +EXTRA_COIN_ALIASES = tuple(sorted(alias for alias in COIN_ALIASES if alias not in SUPPORTED_COINS)) + + +def launch_tui() -> None: + from .app import CheckAddyApp + + CheckAddyApp().run() + + +def normalize_coin(value: str) -> str: + coin = value.strip().upper() + normalized = COIN_ALIASES.get(coin, coin) + if normalized not in SUPPORTED_COINS: + supported = ", ".join(SUPPORTED_COINS + EXTRA_COIN_ALIASES) + raise argparse.ArgumentTypeError(f"unsupported coin '{value}'. Supported: {supported}") + return normalized + + +def detect_matching_coins(address: str) -> list[tuple[str, str]]: + matches: list[tuple[str, str]] = [] + for coin in SUPPORTED_COINS: + is_valid, reason = validate_address(coin, address) + if is_valid: + matches.append((coin, reason)) + return matches + + +def resolve_coin(address: str, requested_coin: str | None) -> tuple[str | None, str | None]: + if requested_coin is not None: + return requested_coin, None + + matches = detect_matching_coins(address) + if not matches: + return None, "Address does not match any supported network format." + if len(matches) == 1: + return matches[0][0], None + + choices = ", ".join(coin for coin, _ in matches) + return None, f"Address is ambiguous across networks: {choices}. Re-run with --coin." + + +def render_text_result(result: LookupResult) -> str: + tx_count = str(result.tx_count) if result.tx_count is not None else "N/A" + status = "api skipped" if result.api_skipped else "api error" if result.api_error else "ok" + lines = [ + "checkaddy", + f"Status: {status}", + f"Coin: {result.coin} ({COIN_DISPLAY_SYMBOL[result.coin]})", + f"Address: {result.address}", + f"Validation: {format_validation_badge(result.is_valid_format, result.validation_reason)}", + f"Confirmed balance: {format_amount_display(result.coin, result.confirmed_balance)}", + f"Unconfirmed balance: {format_amount_display(result.coin, result.unconfirmed_balance)}", + f"Total received: {format_amount_display(result.coin, result.total_received)}", + f"Total sent: {format_amount_display(result.coin, result.total_sent)}", + f"Transaction count: {tx_count}", + f"Data source: {result.data_source}", + f"Explorer: {result.explorer_url}", + f"Fetched at UTC: {result.fetched_at_utc}", + ] + if result.api_error: + lines.append(f"API error: {result.api_error}") + if result.api_skipped: + lines.append("Remote lookup skipped because the address format is invalid.") + return "\n".join(lines) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="checkaddy", + description=( + "Check a public cryptocurrency address from the terminal. " + "Run without arguments to launch the Textual TUI." + ), + ) + parser.add_argument("address", nargs="?", help="public wallet address to validate and inspect") + parser.add_argument( + "-c", + "--coin", + type=normalize_coin, + help="network to check: BTC, LTC, DOGE, DASH, BCH, ETH, BSC, POLYGON/MATIC", + ) + parser.add_argument("--json", action="store_true", help="print normalized JSON output") + parser.add_argument("--tui", action="store_true", help="launch the Textual TUI") + return parser + + +def run_lookup(address: str, coin: str, *, as_json: bool) -> int: + client = ApiClient() + try: + result = build_lookup_result(client, coin, address) + finally: + client.close() + + if as_json: + print(json.dumps(result.as_dict(), indent=2, ensure_ascii=False)) + else: + print(render_text_result(result)) + + if result.api_skipped: + return 2 + if result.api_error: + return 1 + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + args_list = list(sys.argv[1:] if argv is None else argv) + if not args_list: + launch_tui() + return 0 + + parser = build_parser() + args = parser.parse_args(args_list) + + if args.tui: + launch_tui() + return 0 + + if not args.address: + parser.error("address is required unless running without arguments or using --tui") + + coin, error = resolve_coin(args.address.strip(), args.coin) + if error is not None: + if args.json: + print(json.dumps({"error": error}, indent=2), file=sys.stderr) + else: + print(f"checkaddy: {error}", file=sys.stderr) + return 2 + + assert coin is not None + return run_lookup(args.address.strip(), coin, as_json=args.json) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/checkaddy_app/main.py b/checkaddy_app/main.py index 8bb6da8..5417fda 100644 --- a/checkaddy_app/main.py +++ b/checkaddy_app/main.py @@ -1,11 +1,7 @@ from __future__ import annotations -from .app import CheckAddyApp - - -def main() -> None: - CheckAddyApp().run() +from .cli import main if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0c2eeb4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "checkaddy" +version = "1.0.0" +description = "Terminal UI and CLI for checking public cryptocurrency address balances." +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [{ name = "zv" }] +dependencies = [ + "requests", + "textual", +] + +[project.scripts] +checkaddy = "checkaddy_app.cli:main" + +[tool.setuptools.packages.find] +include = ["checkaddy_app*"] diff --git a/run.py b/run.py index e3225c0..16c185d 100644 --- a/run.py +++ b/run.py @@ -5,4 +5,4 @@ from checkaddy_app.main import main if __name__ == "__main__": - main() + raise SystemExit(main())