Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Optional:

```bash
python -m shellshop --merchant-name "Sats & Supply"
python -m shellshop --config sample.yaml
python -m unittest
```

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ authors = [
]
dependencies = [
"textual",
"PyYAML",
]

[project.scripts]
Expand Down
28 changes: 28 additions & 0 deletions sample.yaml
Original file line number Diff line number Diff line change
@@ -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

6 changes: 5 additions & 1 deletion shellshop/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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__":
Expand Down
60 changes: 48 additions & 12 deletions shellshop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpicking here, but keep whitespaces consistent

color: #88d0c6;
content-align: center middle;
text-align: center;
margin: 1 0 0 0;
}

#main {
Expand All @@ -92,7 +112,8 @@ class StorefrontApp(App[None]):
}

#content {
width: 108;
width: 100%;
max-width: 108;
height: 1fr;
border: heavy #f7931a;
background: ansi_default;
Expand All @@ -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;
Expand All @@ -111,7 +133,8 @@ class StorefrontApp(App[None]):
}

#bindings {
height: 2;
height: auto;
min-height: 2;
background: ansi_default;
color: #d8e9f3;
padding: 0 1;
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
104 changes: 104 additions & 0 deletions shellshop/loader.py
Original file line number Diff line number Diff line change
@@ -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