diff --git a/README.md b/README.md index ffcb664..432f479 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Optional: ```bash python -m shellshop --merchant-name "Sats & Supply" +python -m shellshop --config sample.yaml python -m unittest ``` diff --git a/pyproject.toml b/pyproject.toml index 40bfca3..1f8340e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ authors = [ ] dependencies = [ "textual", + "PyYAML", ] [project.scripts] diff --git a/sample.yaml b/sample.yaml new file mode 100644 index 0000000..b7266f2 --- /dev/null +++ b/sample.yaml @@ -0,0 +1,28 @@ +merchant: + name: BitPolito Shop + headline: Custom terminal storefront + location: Torino , Italy + promise: We sell custom things +catalog: + - sku: item-01 + name: BitPolito T-shirt + tagline: A very custom item + description: Blue T-shirt + category: Tshirt + price_sats: 100000 + stock: 5 + features: + - Custom feature 1 + - Custom feature 2 + + - sku: item-02 + name: BitPolito Cap + tagline: Cap hat with cow logo + description: White Cap + category: hat + price_sats: 50000 + stock: 15 + features: + - Custom feature 1 + - Custom feature 2 + diff --git a/shellshop/__main__.py b/shellshop/__main__.py index 07cb7c7..0f3142b 100644 --- a/shellshop/__main__.py +++ b/shellshop/__main__.py @@ -13,6 +13,10 @@ def build_parser() -> argparse.ArgumentParser: "--merchant-name", help="Override the demo merchant name shown in the storefront.", ) + parser.add_argument( + "--config", + help="Path to a YAML configuration file containing the merchant profile and catalog.", + ) parser.add_argument( "--version", action="version", @@ -25,7 +29,7 @@ def main() -> None: args = build_parser().parse_args() from .app import run - run(merchant_name=args.merchant_name) + run(merchant_name=args.merchant_name, config_path=args.config) if __name__ == "__main__": diff --git a/shellshop/app.py b/shellshop/app.py index 4eec319..a57e623 100644 --- a/shellshop/app.py +++ b/shellshop/app.py @@ -9,14 +9,15 @@ from textual.app import App, ComposeResult from textual.containers import Vertical from textual.widgets import Static +from textual.containers import Container from .catalog import demo_catalog, demo_merchant, format_price_sats from .store import StoreState LOGO = r""" ▄█████ ▄▄ ▄▄ ▄▄▄▄▄ ▄▄ ▄▄ ▄█████ ▄▄ ▄▄ ▄▄▄ ▄▄▄▄ - ▀▀▀▄▄▄ ██▄██ ██▄▄ ██ ██ ▀▀▀▄▄▄ ██▄██ ██▀██ ██▄█▀ - █████▀ ██ ██ ██▄▄▄ ██▄▄▄ ██▄▄▄ █████▀ ██ ██ ▀███▀ ██ + ▀▀▀▄▄▄ ██▄██ ██▄▄ ██ ██ ▀▀▀▄▄▄ ██▄██ ██▀██ ██▄█▀ +█████▀ ██ ██ ██▄▄▄ ██▄▄▄ ██▄▄▄ █████▀ ██ ██ ▀███▀ ██ """.strip("\n") HOST_FINGERPRINT = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGhvc3Qta2V5LXNhdHMtcHJpdmFjeS1ub2Rl" @@ -59,22 +60,41 @@ class StorefrontApp(App[None]): background: ansi_default; } + #hero-container { + width: 100%; + height: auto; + min-height: 14; + align: center middle; + } + #hero { - height: 14; + height: auto; + min-height: 10; + width: 100%; + max-width: 108; border: heavy #f7931a; background: ansi_default; color: #f6e7c6; + content-align: center middle; + text-align: center; padding: 1 2; } + #container-for-tabs { + width: 100%; + height: auto; + min-height: 3; + align: center middle; + } + #tabs { height: 3; + width: 100%; + max-width: 80; border: round #2f8f83; background: ansi_default; color: #88d0c6; content-align: center middle; - text-align: center; - margin: 1 0 0 0; } #main { @@ -92,7 +112,8 @@ class StorefrontApp(App[None]): } #content { - width: 108; + width: 100%; + max-width: 108; height: 1fr; border: heavy #f7931a; background: ansi_default; @@ -101,7 +122,8 @@ class StorefrontApp(App[None]): } #status { - height: 6; + height: auto; + min-height: 6; border: round #577283; border-subtitle-align: center; background: ansi_default; @@ -111,7 +133,8 @@ class StorefrontApp(App[None]): } #bindings { - height: 2; + height: auto; + min-height: 2; background: ansi_default; color: #d8e9f3; padding: 0 1; @@ -143,8 +166,10 @@ def __init__(self, store: StoreState) -> None: def compose(self) -> ComposeResult: with Vertical(id="frame"): - yield Static(id="hero") - yield Static(id="tabs") + with Container(id="hero-container"): + yield Static(id="hero") + with Container(id="container-for-tabs"): + yield Static(id="tabs") with Vertical(id="main"): with Vertical(id="content-wrap"): yield Static(id="content") @@ -443,8 +468,19 @@ def render_bindings(self) -> Text: return text -def run(merchant_name: str | None = None) -> None: +def run(merchant_name: str | None = None, config_path: str | None = None) -> None: """Start the Textual storefront app.""" - store = StoreState(merchant=demo_merchant(merchant_name), products=demo_catalog()) + if config_path: + import sys + from .loader import load_yaml_catalog + try: + merchant, products = load_yaml_catalog(config_path) + except ValueError as e: + print(f"Error loading configuration: {e}") + sys.exit(1) + store = StoreState(merchant=merchant, products=products) + else: + store = StoreState(merchant=demo_merchant(merchant_name), products=demo_catalog()) + StorefrontApp(store).run() diff --git a/shellshop/loader.py b/shellshop/loader.py new file mode 100644 index 0000000..3223f1a --- /dev/null +++ b/shellshop/loader.py @@ -0,0 +1,104 @@ +"""YAML catalog loader for ShellShop.""" + +from __future__ import annotations + +import yaml +from typing import Any + +from .catalog import MerchantProfile, Product + + +def load_yaml_catalog(path: str) -> tuple[MerchantProfile, list[Product]]: + """Load and validate a merchant profile and catalog from a YAML file.""" + + with open(path, "r", encoding="utf-8") as f: + try: + data = yaml.safe_load(f) + except yaml.YAMLError as e: + raise ValueError(f"Failed to parse YAML configuration: {e}") + + if not isinstance(data, dict): + raise ValueError("Root of YAML configuration must be a dictionary.") + + merchant = _parse_merchant(data.get("merchant")) + products = _parse_catalog(data.get("catalog")) + + return merchant, products + + +def _parse_merchant(data: Any) -> MerchantProfile: + if not isinstance(data, dict): + raise ValueError("Missing or invalid 'merchant' section in configuration.") + + required_fields = ["name", "headline", "location", "promise"] + for field in required_fields: + if field not in data: + raise ValueError(f"Merchant profile missing required field: '{field}'") + if not isinstance(data[field], str): + raise ValueError(f"Merchant profile field '{field}' must be a string") + + return MerchantProfile( + name=data["name"], + headline=data["headline"], + location=data["location"], + promise=data["promise"], + ) + + +def _parse_catalog(data: Any) -> list[Product]: + if not isinstance(data, list): + raise ValueError("Missing or invalid 'catalog' section, must be a list.") + + products = [] + for i, prod_data in enumerate(data): + if not isinstance(prod_data, dict): + raise ValueError(f"Product at index {i} must be a dictionary.") + + identifier = prod_data.get("sku") or f"index {i}" + + # Required string fields + for field in ["sku", "name"]: + if field not in prod_data: + raise ValueError(f"Product '{identifier}' missing required field: '{field}'") + if not isinstance(prod_data[field], str): + raise ValueError(f"Product '{identifier}' field '{field}' must be a string") + + # Optional string fields + for field in ["tagline", "description", "category"]: + val = prod_data.get(field) + if val is None: + prod_data[field] = "" + elif not isinstance(val, str): + raise ValueError(f"Product '{identifier}' field '{field}' must be a string or null") + + # Integer fields + for field in ["price_sats", "stock"]: + if field not in prod_data: + raise ValueError(f"Product '{identifier}' missing required field: '{field}'") + if not isinstance(prod_data[field], int): + raise ValueError(f"Product '{identifier}' field '{field}' must be an integer") + + # List of strings field (optional) + features_data = prod_data.get("features") + if features_data is None: + features_data = [] + elif not isinstance(features_data, list): + raise ValueError(f"Product '{identifier}' field 'features' must be a list of strings") + for j, feature in enumerate(features_data): + if not isinstance(feature, str): + raise ValueError(f"Product '{identifier}' feature at index {j} must be a string") + + products.append( + Product( + sku=prod_data["sku"], + name=prod_data["name"], + tagline=prod_data["tagline"], + description=prod_data["description"], + category=prod_data["category"], + price_sats=prod_data["price_sats"], + stock=prod_data["stock"], + features=tuple(features_data), + ) + ) + + return products