diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4728d17a..8b372b87 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,7 +31,7 @@ on:
- main
env:
- VERSION_NUMBER: 'v1.10.2'
+ VERSION_NUMBER: 'v1.10.3'
DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli'
AWS_REGION: 'us-west-2'
@@ -59,10 +59,38 @@ jobs:
with:
sarif_file: results.sarif
+ bandit:
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.12'
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v7
+
+ - name: Run Bandit Security Scanner
+ run: |
+ uv tool run --from 'bandit[sarif,toml]' bandit \
+ -r card_data/pipelines \
+ -f sarif \
+ -o bandit-results.sarif \
+ || true
+
+ - name: Upload SARIF Report
+ uses: github/codeql-action/upload-sarif@v4
+ with:
+ sarif_file: bandit-results.sarif
+
gitleaks:
runs-on: ubuntu-22.04
- needs: [gosec]
- if: needs.gosec.result == 'success'
+ needs: [gosec, bandit]
+ if: needs.gosec.result == 'success' && needs.bandit.result == 'success'
steps:
- name: Checkout
diff --git a/.github/workflows/python_testing.yml b/.github/workflows/python_test.yml
similarity index 93%
rename from .github/workflows/python_testing.yml
rename to .github/workflows/python_test.yml
index cb44564c..806aa058 100644
--- a/.github/workflows/python_testing.yml
+++ b/.github/workflows/python_test.yml
@@ -1,6 +1,12 @@
name: Python Tests
on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'card_data/**'
+ - 'web/**'
pull_request:
types: [opened, reopened, synchronize]
paths:
diff --git a/.goreleaser.yml b/.goreleaser.yml
index eb5a0e06..97e1b479 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -14,7 +14,7 @@ builds:
- windows
- darwin
ldflags:
- - -s -w -X main.version=v1.10.2
+ - -s -w -X main.version=v1.10.3
archives:
- formats: [ 'zip' ]
diff --git a/Dockerfile b/Dockerfile
index 411f8b04..5877be4c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# build 1
-FROM golang:1.25.9-alpine3.23 AS build
+FROM golang:1.25.10-alpine3.23 AS build
WORKDIR /app
@@ -8,7 +8,7 @@ RUN go mod download
COPY . .
-RUN go build -ldflags "-X main.version=v1.10.2" -o poke-cli .
+RUN go build -ldflags "-X main.version=v1.10.3" -o poke-cli .
# build 2
FROM --platform=$BUILDPLATFORM alpine:3.23
diff --git a/README.md b/README.md
index b3272b95..a29f5fd0 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+
@@ -22,6 +22,9 @@ View the [documentation](https://docs.poke-cli.com) on the data infrastructure i
* [Roadmap](#roadmap)
* [Tested Terminals](#tested-terminals)
+> ![NOTE]
+> A version 2 is being planned and built that will remove/update some commands and flags. Refer to the changes under the [Roadmap](#version-2-changes) for more information.
+
---
## Demo
@@ -99,11 +102,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
3. Choose how to interact with the container:
* Run a single command and exit:
```bash
- docker run --rm -it digitalghostdev/poke-cli:v1.10.2 [subcommand] [flag]
+ docker run --rm -it digitalghostdev/poke-cli:v1.10.3 [subcommand] [flag]
```
* Enter the container and use its shell:
```bash
- docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.2 -c "cd /app && exec sh"
+ docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.3 -c "cd /app && exec sh"
# placed into the /app directory, run the program with './poke-cli'
# example: ./poke-cli ability swift-swim
```
@@ -112,13 +115,13 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
> The `card` command renders TCG card images using your terminal's graphics protocol. When running inside Docker, pass your terminal's environment variables so image rendering works correctly:
> ```bash
> # Kitty
-> docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.2 card
+> docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.3 card
>
> # WezTerm, iTerm2, Ghostty, Konsole, Rio, Tabby
-> docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.2 card
+> docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.3 card
>
> # Windows Terminal (Sixel)
-> docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.2 card
+> docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.3 card
> ```
> If your terminal is not listed above, image rendering is not supported inside Docker.
@@ -235,6 +238,16 @@ Below is a list of the planned/completed commands and flags:
- [x] `tcg`: get data about TCG tournaments.
- [x] `types`: get data about a specific typing.
+### Version 2 Changes
+The following planned changes in `v2`:
+
+- `pokemon -t | --types` — removed; typing is included by default.
+- `pokemon --defense` - being renamed to `--defenses` to keep consistency with other flags in the `pokemon` command.
+- `natures` — moves to a flag under a new `mechanics` command.
+- `tcg` — moves to a new `comp` command (covers competitive TCG *and* VGC data).
+- Adding `pflag` library to enforce POSIX style flags.
+
+
---
## Tested Terminals
| Terminal | OS | Status | Issues |
diff --git a/card_data/pipelines/definitions.py b/card_data/pipelines/definitions.py
index f9098ee2..20dc7786 100644
--- a/card_data/pipelines/definitions.py
+++ b/card_data/pipelines/definitions.py
@@ -9,7 +9,10 @@
from .defs.extract.tcgdex.extract_sets import extract_sets_data
from .defs.extract.tcgdex.extract_series import extract_series_data
from .defs.load.limitless.load_standings import load_standings_data
-from .defs.load.tcgcsv.load_pricing import load_pricing_data, data_quality_checks_on_pricing
+from .defs.load.tcgcsv.load_pricing import (
+ load_pricing_data,
+ data_quality_checks_on_pricing,
+)
from .defs.load.tcgdex.load_sets import load_sets_data, data_quality_check_on_sets
from .defs.load.tcgdex.load_series import load_series_data, data_quality_check_on_series
from .sensors import discord_success_sensor, discord_failure_sensor
@@ -18,7 +21,9 @@
@definitions
def defs() -> dg.Definitions:
# load_from_defs_folder discovers dbt assets from transform_data.py
- folder_defs: dg.Definitions = load_from_defs_folder(project_root=Path(__file__).parent.parent)
+ folder_defs: dg.Definitions = load_from_defs_folder(
+ project_root=Path(__file__).parent.parent
+ )
return dg.Definitions.merge(
folder_defs,
defs_discord_sensors,
@@ -56,7 +61,9 @@ def defs() -> dg.Definitions:
# Series pipeline job
series_pipeline = dg.define_asset_job(
name="series_pipeline_job",
- selection=dg.AssetSelection.assets(extract_series_data).downstream(include_self=True),
+ selection=dg.AssetSelection.assets(extract_series_data).downstream(
+ include_self=True
+ ),
)
defs_series: dg.Definitions = dg.Definitions(
@@ -78,7 +85,9 @@ def defs() -> dg.Definitions:
# Standings pipeline job
standings_pipeline = dg.define_asset_job(
name="standings_pipeline_job",
- selection=dg.AssetSelection.assets(create_standings_dataframe).downstream(include_self=True),
+ selection=dg.AssetSelection.assets(create_standings_dataframe).downstream(
+ include_self=True
+ ),
)
defs_standings: dg.Definitions = dg.Definitions(
@@ -94,4 +103,4 @@ def defs() -> dg.Definitions:
defs_champions_speed_tiers: dg.Definitions = dg.Definitions(
jobs=[champions_speed_tiers_pipeline],
-)
\ No newline at end of file
+)
diff --git a/card_data/pipelines/defs/extract/limitless/extract_standings.py b/card_data/pipelines/defs/extract/limitless/extract_standings.py
index c8d24a5e..f07305c3 100644
--- a/card_data/pipelines/defs/extract/limitless/extract_standings.py
+++ b/card_data/pipelines/defs/extract/limitless/extract_standings.py
@@ -3,10 +3,11 @@
import requests
from bs4 import BeautifulSoup, Tag
+
def seasons(year: int) -> list[dict]:
url = "https://labs.limitlesstcg.com/"
r = requests.get(url)
- soup = BeautifulSoup(r.content, 'html.parser')
+ soup = BeautifulSoup(r.content, "html.parser")
season_header = soup.find("h2", string=lambda x: bool(x and f"{year}" in x))
if not isinstance(season_header, Tag):
@@ -20,11 +21,13 @@ def seasons(year: int) -> list[dict]:
if not isinstance(a, Tag):
continue
parts = list(a.stripped_strings)
- tournaments.append({
- "name": parts[0],
- "date": parts[-1],
- "link": f"https://labs.limitlesstcg.com{a['href']}",
- })
+ tournaments.append(
+ {
+ "name": parts[0],
+ "date": parts[-1],
+ "link": f"https://labs.limitlesstcg.com{a['href']}",
+ }
+ )
return tournaments
@@ -33,22 +36,33 @@ def build_standings(tournament: dict) -> pl.DataFrame | None:
tournament_id = tournament["link"].split("/")[-2]
r = requests.get(tournament["link"])
- soup = BeautifulSoup(r.content, 'html.parser')
+ soup = BeautifulSoup(r.content, "html.parser")
table = soup.find("table", class_="data-table striped")
if isinstance(table, Tag):
- headers = ['Rank', 'Name', 'Country', 'Points', 'Record', 'OPW%', 'OOPW%', 'Deck', 'Decklist', 'Unknown']
+ headers = [
+ "Rank",
+ "Name",
+ "Country",
+ "Points",
+ "Record",
+ "OPW%",
+ "OOPW%",
+ "Deck",
+ "Decklist",
+ "Unknown",
+ ]
rows = []
- tbody = table.find('tbody')
+ tbody = table.find("tbody")
if not isinstance(tbody, Tag):
return None
- for tr in tbody.find_all('tr'):
+ for tr in tbody.find_all("tr"):
if not isinstance(tr, Tag):
continue
- cells = tr.find_all('td')
+ cells = tr.find_all("td")
if len(cells) == 1:
continue
@@ -58,29 +72,37 @@ def build_standings(tournament: dict) -> pl.DataFrame | None:
if not isinstance(td, Tag):
continue
if i == 2:
- img = td.find('img')
+ img = td.find("img")
if isinstance(img, Tag):
- country = img.get('alt') or img.get('title') or ''
+ country = img.get("alt") or img.get("title") or ""
row_data.append(country)
else:
- row_data.append('')
+ row_data.append("")
elif i == 7: # Deck column
- pokemon_imgs = td.find_all('img', class_='pokemon')
+ pokemon_imgs = td.find_all("img", class_="pokemon")
if pokemon_imgs:
- pokemon_names = [str(img.get('alt', '')) for img in pokemon_imgs if isinstance(img, Tag) and img.get('alt')]
- pokemon_string = '/'.join(pokemon_names)
+ pokemon_names = [
+ str(img.get("alt", ""))
+ for img in pokemon_imgs
+ if isinstance(img, Tag) and img.get("alt")
+ ]
+ pokemon_string = "/".join(pokemon_names)
row_data.append(pokemon_string)
else:
- row_data.append('')
+ row_data.append("")
elif i == 8: # Decklist column
- link = td.find('a')
+ link = td.find("a")
if isinstance(link, Tag):
- decklist_url = link.get('href', '')
- row_data.append(f"https://labs.limitlesstcg.com{decklist_url}" if decklist_url else '')
+ decklist_url = link.get("href", "")
+ row_data.append(
+ f"https://labs.limitlesstcg.com{decklist_url}"
+ if decklist_url
+ else ""
+ )
else:
- row_data.append('')
+ row_data.append("")
else:
cell_text = td.get_text(strip=True)
diff --git a/card_data/pipelines/defs/extract/tcgcsv/extract_pricing.py b/card_data/pipelines/defs/extract/tcgcsv/extract_pricing.py
index f4213b0f..f176455b 100644
--- a/card_data/pipelines/defs/extract/tcgcsv/extract_pricing.py
+++ b/card_data/pipelines/defs/extract/tcgcsv/extract_pricing.py
@@ -65,7 +65,7 @@
"sm3.5": "2054",
"sm3": "1957",
"sm2": "1919",
- "sm1": "1863"
+ "sm1": "1863",
}
diff --git a/card_data/pipelines/defs/extract/tcgdex/extract_series.py b/card_data/pipelines/defs/extract/tcgdex/extract_series.py
index 83f68f84..1f3850bb 100644
--- a/card_data/pipelines/defs/extract/tcgdex/extract_series.py
+++ b/card_data/pipelines/defs/extract/tcgdex/extract_series.py
@@ -31,6 +31,8 @@ def extract_series_data() -> pl.DataFrame:
raise
filtered = [
- s.model_dump(mode="json") for s in validated if s.id in ["me", "sv", "swsh", "sm"]
+ s.model_dump(mode="json")
+ for s in validated
+ if s.id in ["me", "sv", "swsh", "sm"]
]
return pl.DataFrame(filtered)
diff --git a/card_data/pipelines/defs/load/tcgcsv/load_pricing.py b/card_data/pipelines/defs/load/tcgcsv/load_pricing.py
index 28dfcdd5..90c11432 100644
--- a/card_data/pipelines/defs/load/tcgcsv/load_pricing.py
+++ b/card_data/pipelines/defs/load/tcgcsv/load_pricing.py
@@ -1,4 +1,4 @@
-import subprocess # nosec
+import subprocess # nosec
from pathlib import Path
import dagster as dg
@@ -37,7 +37,7 @@ def data_quality_checks_on_pricing() -> None:
current_file_dir = Path(__file__).parent
print(f"Setting cwd to: {current_file_dir}")
- result = subprocess.run( # nosec
+ result = subprocess.run( # nosec
[
"soda",
"scan",
@@ -58,4 +58,6 @@ def data_quality_checks_on_pricing() -> None:
print(result.stderr)
if result.returncode != 0:
- raise Exception(f"Soda data quality checks failed with return code {result.returncode}")
+ raise Exception(
+ f"Soda data quality checks failed with return code {result.returncode}"
+ )
diff --git a/card_data/pipelines/defs/transform/transform_data.py b/card_data/pipelines/defs/transform/transform_data.py
index c365c314..61f50d3d 100644
--- a/card_data/pipelines/defs/transform/transform_data.py
+++ b/card_data/pipelines/defs/transform/transform_data.py
@@ -4,6 +4,7 @@
DBT_PROJECT_PATH = Path(__file__).joinpath("..", "..", "..", "poke_cli_dbt").resolve()
+
class CustomDbtTranslator(DagsterDbtTranslator):
def get_asset_key(self, dbt_resource_props):
@@ -25,9 +26,10 @@ def get_asset_key(self, dbt_resource_props):
# For models, use default behavior
return super().get_asset_key(dbt_resource_props)
+
@dbt_assets(
manifest=DBT_PROJECT_PATH / "target" / "manifest.json",
- dagster_dbt_translator=CustomDbtTranslator()
+ dagster_dbt_translator=CustomDbtTranslator(),
)
def dbt_build_assets(context: dg.AssetExecutionContext, dbt: DbtCliResource):
"""
@@ -35,8 +37,6 @@ def dbt_build_assets(context: dg.AssetExecutionContext, dbt: DbtCliResource):
"""
yield from dbt.cli(["build"], context=context).stream()
+
dbt_resource = DbtCliResource(project_dir=DBT_PROJECT_PATH)
-defs = dg.Definitions(
- assets=[dbt_build_assets],
- resources={"dbt": dbt_resource}
-)
+defs = dg.Definitions(assets=[dbt_build_assets], resources={"dbt": dbt_resource})
diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml
index 6839ad11..c8396ccc 100644
--- a/card_data/pipelines/poke_cli_dbt/dbt_project.yml
+++ b/card_data/pipelines/poke_cli_dbt/dbt_project.yml
@@ -1,5 +1,5 @@
name: 'poke_cli_dbt'
-version: '1.10.2'
+version: '1.10.3'
profile: 'poke_cli_dbt'
diff --git a/card_data/pipelines/sensors.py b/card_data/pipelines/sensors.py
index 00bdd815..d100f5cd 100644
--- a/card_data/pipelines/sensors.py
+++ b/card_data/pipelines/sensors.py
@@ -41,4 +41,4 @@ def discord_failure_sensor(context: RunStatusSensorContext):
except requests.RequestException as e:
context.log.error(f"Requests or network error: {e}")
except Exception as e:
- context.log.error(f"Failed to send notification: {e}")
\ No newline at end of file
+ context.log.error(f"Failed to send notification: {e}")
diff --git a/card_data/pipelines/tests/extract_pricing_test.py b/card_data/pipelines/tests/extract_pricing_test.py
index 161ce408..ee360310 100644
--- a/card_data/pipelines/tests/extract_pricing_test.py
+++ b/card_data/pipelines/tests/extract_pricing_test.py
@@ -155,8 +155,14 @@ def _make_product(product_id: int, name: str, card_number: str) -> dict:
}
-def _make_price(product_id: int, market_price: float | None, sub_type: str = "Normal") -> dict:
- return {"productId": product_id, "marketPrice": market_price, "subTypeName": sub_type}
+def _make_price(
+ product_id: int, market_price: float | None, sub_type: str = "Normal"
+) -> dict:
+ return {
+ "productId": product_id,
+ "marketPrice": market_price,
+ "subTypeName": sub_type,
+ }
@responses.activate
@@ -165,19 +171,23 @@ def test_pull_product_information_success(benchmark):
responses.add(
responses.GET,
f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
- json={"results": [
- _make_product(1001, "Pikachu", "025/198"),
- _make_product(1002, "Charizard", "006/198"),
- ]},
+ json={
+ "results": [
+ _make_product(1001, "Pikachu", "025/198"),
+ _make_product(1002, "Charizard", "006/198"),
+ ]
+ },
status=200,
)
responses.add(
responses.GET,
f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices",
- json={"results": [
- _make_price(1001, 1.50),
- _make_price(1002, None),
- ]},
+ json={
+ "results": [
+ _make_price(1001, 1.50),
+ _make_price(1002, None),
+ ]
+ },
status=200,
)
@@ -195,21 +205,25 @@ def test_pull_product_information_skips_variants(benchmark):
responses.add(
responses.GET,
f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
- json={"results": [
- _make_product(1001, "Pikachu", "025/198"),
- _make_product(1002, "Pikachu (Poke Ball Pattern)", "025/198"),
- _make_product(1003, "Pikachu (Master Ball Pattern)", "025/198"),
- ]},
+ json={
+ "results": [
+ _make_product(1001, "Pikachu", "025/198"),
+ _make_product(1002, "Pikachu (Poke Ball Pattern)", "025/198"),
+ _make_product(1003, "Pikachu (Master Ball Pattern)", "025/198"),
+ ]
+ },
status=200,
)
responses.add(
responses.GET,
f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices",
- json={"results": [
- _make_price(1001, 2.00),
- _make_price(1002, 3.00),
- _make_price(1003, 4.00),
- ]},
+ json={
+ "results": [
+ _make_price(1001, 2.00),
+ _make_price(1002, 3.00),
+ _make_price(1003, 4.00),
+ ]
+ },
status=200,
)
@@ -225,10 +239,12 @@ def test_pull_product_information_skips_non_cards(benchmark):
responses.add(
responses.GET,
f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
- json={"results": [
- _make_product(1001, "Pikachu", "025/198"),
- {"productId": 1002, "name": "Booster Pack", "extendedData": []},
- ]},
+ json={
+ "results": [
+ _make_product(1001, "Pikachu", "025/198"),
+ {"productId": 1002, "name": "Booster Pack", "extendedData": []},
+ ]
+ },
status=200,
)
responses.add(
@@ -267,7 +283,9 @@ def test_pull_product_information_sm_normalizes_card_number(benchmark):
@responses.activate
def test_pull_product_information_excludes_reverse_holofoil_prices(benchmark):
- product_id = "22873" # sv01 — both Normal and Reverse Holofoil entries for same card
+ product_id = (
+ "22873" # sv01 — both Normal and Reverse Holofoil entries for same card
+ )
responses.add(
responses.GET,
f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
@@ -277,10 +295,12 @@ def test_pull_product_information_excludes_reverse_holofoil_prices(benchmark):
responses.add(
responses.GET,
f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices",
- json={"results": [
- _make_price(1001, 5.00, "Reverse Holofoil"),
- _make_price(1001, 1.50, "Normal"),
- ]},
+ json={
+ "results": [
+ _make_price(1001, 5.00, "Reverse Holofoil"),
+ _make_price(1001, 1.50, "Normal"),
+ ]
+ },
status=200,
)
@@ -296,13 +316,15 @@ def test_pull_product_information_validation_error_raises(benchmark):
responses.add(
responses.GET,
f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
- json={"results": [
- {
- "productId": "not-an-integer",
- "name": "Bad Card",
- "extendedData": [{"name": "Number", "value": "999/198"}],
- }
- ]},
+ json={
+ "results": [
+ {
+ "productId": "not-an-integer",
+ "name": "Bad Card",
+ "extendedData": [{"name": "Number", "value": "999/198"}],
+ }
+ ]
+ },
status=200,
)
responses.add(
@@ -326,12 +348,14 @@ def run():
@patch("pipelines.defs.extract.tcgcsv.extract_pricing.pull_product_information")
def test_build_dataframe_concatenates_all_sets(mock_pull, benchmark):
- sample_df = pl.DataFrame({
- "product_id": [1001],
- "name": ["Pikachu"],
- "card_number": ["025/198"],
- "market_price": [1.50],
- })
+ sample_df = pl.DataFrame(
+ {
+ "product_id": [1001],
+ "name": ["Pikachu"],
+ "card_number": ["025/198"],
+ "market_price": [1.50],
+ }
+ )
mock_pull.return_value = sample_df
result = benchmark(build_dataframe)
diff --git a/card_data/pipelines/tests/extract_series_test.py b/card_data/pipelines/tests/extract_series_test.py
index 999a5b87..2e12a515 100644
--- a/card_data/pipelines/tests/extract_series_test.py
+++ b/card_data/pipelines/tests/extract_series_test.py
@@ -16,9 +16,17 @@ def mock_api_response():
"""Sample API response matching tcgdex format"""
return [
{"id": "sv", "name": "Scarlet & Violet", "logo": "https://example.com/sv.png"},
- {"id": "swsh", "name": "Sword & Shield", "logo": "https://example.com/swsh.png"},
+ {
+ "id": "swsh",
+ "name": "Sword & Shield",
+ "logo": "https://example.com/swsh.png",
+ },
{"id": "xy", "name": "XY", "logo": "https://example.com/xy.png"},
- {"id": "me", "name": "McDonald's Collection", "logo": "https://example.com/me.png"},
+ {
+ "id": "me",
+ "name": "McDonald's Collection",
+ "logo": "https://example.com/me.png",
+ },
{"id": "sm", "name": "Sun & Moon", "logo": None},
]
@@ -30,7 +38,7 @@ def test_extract_series_data_success(benchmark, mock_api_response):
responses.GET,
"https://api.tcgdex.net/v2/en/series",
json=mock_api_response,
- status=200
+ status=200,
)
result = benchmark(extract_series_data)
@@ -48,7 +56,9 @@ def test_extract_series_data_validation_error(benchmark):
responses.add(
responses.GET,
"https://api.tcgdex.net/v2/en/series",
- json=[{"logo": "https://example.com/test.png"}], # missing required 'id' and 'name'
+ json=[
+ {"logo": "https://example.com/test.png"}
+ ], # missing required 'id' and 'name'
status=200,
)
diff --git a/card_data/pipelines/tests/extract_sets_test.py b/card_data/pipelines/tests/extract_sets_test.py
index e086ae47..21da226a 100644
--- a/card_data/pipelines/tests/extract_sets_test.py
+++ b/card_data/pipelines/tests/extract_sets_test.py
@@ -107,7 +107,14 @@ def test_extract_sets_data_success(benchmark, mock_api_response):
"symbol",
}
assert set(result["series_id"].to_list()) == {"me", "sv", "swsh", "sm"} # nosec
- assert set(result["set_id"].to_list()) == {"me01", "me02", "sv01", "sv02", "swsh1", "sm1"} # nosec
+ assert set(result["set_id"].to_list()) == {
+ "me01",
+ "me02",
+ "sv01",
+ "sv02",
+ "swsh1",
+ "sm1",
+ } # nosec
@responses.activate
diff --git a/card_data/pipelines/tests/secret_retriever_test.py b/card_data/pipelines/tests/secret_retriever_test.py
index b9664af5..301385c0 100644
--- a/card_data/pipelines/tests/secret_retriever_test.py
+++ b/card_data/pipelines/tests/secret_retriever_test.py
@@ -68,9 +68,7 @@ def test_fetch_secret_empty_json_object(mock_get_session, mock_secret_cache_cls)
def test_fetch_secret_cache_raises(mock_get_session, mock_secret_cache_cls):
"""Test that an exception from SecretCache propagates."""
mock_cache_instance = MagicMock()
- mock_cache_instance.get_secret_string.side_effect = Exception(
- "Secret not found"
- )
+ mock_cache_instance.get_secret_string.side_effect = Exception("Secret not found")
mock_secret_cache_cls.return_value = mock_cache_instance
with pytest.raises(Exception, match="Secret not found"):
@@ -114,9 +112,7 @@ def test_fetch_n8n_webhook_secret_missing_key(mock_get_session, mock_secret_cach
@patch("pipelines.utils.secret_retriever.SecretCache")
@patch("pipelines.utils.secret_retriever.botocore.session.get_session")
-def test_fetch_n8n_webhook_secret_invalid_json(
- mock_get_session, mock_secret_cache_cls
-):
+def test_fetch_n8n_webhook_secret_invalid_json(mock_get_session, mock_secret_cache_cls):
"""Test that invalid JSON in the secret raises JSONDecodeError."""
mock_cache_instance = MagicMock()
mock_cache_instance.get_secret_string.return_value = "{broken"
@@ -142,14 +138,10 @@ def test_fetch_n8n_webhook_secret_empty_json_object(
@patch("pipelines.utils.secret_retriever.SecretCache")
@patch("pipelines.utils.secret_retriever.botocore.session.get_session")
-def test_fetch_n8n_webhook_secret_cache_raises(
- mock_get_session, mock_secret_cache_cls
-):
+def test_fetch_n8n_webhook_secret_cache_raises(mock_get_session, mock_secret_cache_cls):
"""Test that an exception from SecretCache propagates."""
mock_cache_instance = MagicMock()
- mock_cache_instance.get_secret_string.side_effect = Exception(
- "Access denied"
- )
+ mock_cache_instance.get_secret_string.side_effect = Exception("Access denied")
mock_secret_cache_cls.return_value = mock_cache_instance
with pytest.raises(Exception, match="Access denied"):
diff --git a/card_data/pipelines/tests/sensors_test.py b/card_data/pipelines/tests/sensors_test.py
index 0f501b9e..75096bd6 100644
--- a/card_data/pipelines/tests/sensors_test.py
+++ b/card_data/pipelines/tests/sensors_test.py
@@ -25,7 +25,10 @@ def _make_context(run_id: str = "test-run-id", job_name: str = "test-job") -> Ma
# ---------------------------------------------------------------------------
-@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
+@patch(
+ "pipelines.sensors.fetch_n8n_webhook_secret",
+ return_value="https://n8n.example.com/hook",
+)
@patch("pipelines.sensors.requests.post")
def test_discord_success_sensor_posts_webhook(mock_post, mock_secret, benchmark):
mock_post.return_value.status_code = 200
@@ -40,9 +43,17 @@ def test_discord_success_sensor_posts_webhook(mock_post, mock_secret, benchmark)
)
-@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
-@patch("pipelines.sensors.requests.post", side_effect=requests.RequestException("connection refused"))
-def test_discord_success_sensor_handles_request_exception(mock_post, mock_secret, benchmark):
+@patch(
+ "pipelines.sensors.fetch_n8n_webhook_secret",
+ return_value="https://n8n.example.com/hook",
+)
+@patch(
+ "pipelines.sensors.requests.post",
+ side_effect=requests.RequestException("connection refused"),
+)
+def test_discord_success_sensor_handles_request_exception(
+ mock_post, mock_secret, benchmark
+):
ctx = _make_context()
benchmark(_success_fn, ctx) # must not raise
@@ -51,9 +62,14 @@ def test_discord_success_sensor_handles_request_exception(mock_post, mock_secret
assert "connection refused" in ctx.log.error.call_args[0][0] # nosec
-@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
+@patch(
+ "pipelines.sensors.fetch_n8n_webhook_secret",
+ return_value="https://n8n.example.com/hook",
+)
@patch("pipelines.sensors.requests.post", side_effect=Exception("unexpected error"))
-def test_discord_success_sensor_handles_generic_exception(mock_post, mock_secret, benchmark):
+def test_discord_success_sensor_handles_generic_exception(
+ mock_post, mock_secret, benchmark
+):
ctx = _make_context()
benchmark(_success_fn, ctx) # must not raise
@@ -67,7 +83,10 @@ def test_discord_success_sensor_handles_generic_exception(mock_post, mock_secret
# ---------------------------------------------------------------------------
-@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
+@patch(
+ "pipelines.sensors.fetch_n8n_webhook_secret",
+ return_value="https://n8n.example.com/hook",
+)
@patch("pipelines.sensors.requests.post")
def test_discord_failure_sensor_posts_webhook(mock_post, mock_secret, benchmark):
mock_post.return_value.status_code = 200
@@ -82,9 +101,16 @@ def test_discord_failure_sensor_posts_webhook(mock_post, mock_secret, benchmark)
)
-@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
-@patch("pipelines.sensors.requests.post", side_effect=requests.RequestException("timeout"))
-def test_discord_failure_sensor_handles_request_exception(mock_post, mock_secret, benchmark):
+@patch(
+ "pipelines.sensors.fetch_n8n_webhook_secret",
+ return_value="https://n8n.example.com/hook",
+)
+@patch(
+ "pipelines.sensors.requests.post", side_effect=requests.RequestException("timeout")
+)
+def test_discord_failure_sensor_handles_request_exception(
+ mock_post, mock_secret, benchmark
+):
ctx = _make_context()
benchmark(_failure_fn, ctx) # must not raise
@@ -93,9 +119,14 @@ def test_discord_failure_sensor_handles_request_exception(mock_post, mock_secret
assert "timeout" in ctx.log.error.call_args[0][0] # nosec
-@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
+@patch(
+ "pipelines.sensors.fetch_n8n_webhook_secret",
+ return_value="https://n8n.example.com/hook",
+)
@patch("pipelines.sensors.requests.post", side_effect=Exception("service unavailable"))
-def test_discord_failure_sensor_handles_generic_exception(mock_post, mock_secret, benchmark):
+def test_discord_failure_sensor_handles_generic_exception(
+ mock_post, mock_secret, benchmark
+):
ctx = _make_context()
benchmark(_failure_fn, ctx) # must not raise
diff --git a/card_data/pipelines/utils/json_retriever.py b/card_data/pipelines/utils/json_retriever.py
index 0479253b..dd924699 100644
--- a/card_data/pipelines/utils/json_retriever.py
+++ b/card_data/pipelines/utils/json_retriever.py
@@ -1,5 +1,6 @@
import requests
+
def fetch_json(url: str, timeout: int = 30) -> dict:
response = requests.get(url, timeout=timeout)
response.raise_for_status()
diff --git a/card_data/pyproject.toml b/card_data/pyproject.toml
index 7a331c49..125f93c6 100644
--- a/card_data/pyproject.toml
+++ b/card_data/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "card-data"
-version = "v1.10.2"
+version = "v1.10.3"
description = "File directory to store all data related processes for the Pokémon TCG."
readme = "README.md"
requires-python = ">=3.12"
diff --git a/cli.go b/cli.go
index 4dbf0153..e22f4a52 100644
--- a/cli.go
+++ b/cli.go
@@ -50,25 +50,22 @@ func renderCommandList() string {
return sb.String()
}
-func currentVersion() {
+func currentVersion() string {
if version != "(devel)" {
// Use version injected by -ldflags
- fmt.Printf("Version: %s\n", version)
- return
+ return "Version: " + version
}
// Fallback to build info when the version is not set
buildInfo, ok := debug.ReadBuildInfo()
if !ok {
- fmt.Println("Version: unknown (unable to read build info)")
- return
+ return "Version: unknown (unable to read build info)"
}
if buildInfo.Main.Version != "" {
- fmt.Printf("Version: %s\n", buildInfo.Main.Version)
- } else {
- fmt.Println("Version: (devel)")
+ return "Version: " + buildInfo.Main.Version
}
+ return "Version: (devel)"
}
func runCLI(args []string) int {
@@ -155,7 +152,7 @@ func runCLI(args []string) int {
}
return 0
case *currentVersionFlag || *shortCurrentVersionFlag:
- currentVersion()
+ fmt.Println(currentVersion())
return 0
case exists:
return utils.HandleCommandOutput(cmdFunc, remainingArgs)()
diff --git a/cli_test.go b/cli_test.go
index 66398060..3dd42758 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -20,12 +20,12 @@ func TestCurrentVersion(t *testing.T) {
{
name: "Version set by ldflags",
version: "v1.0.2",
- expectedOutput: "Version: v1.0.2\n",
+ expectedOutput: "Version: v1.0.2",
},
{
name: "Version set to (devel)",
version: "(devel)",
- expectedOutput: "Version: (devel)\n",
+ expectedOutput: "Version: (devel)",
},
}
@@ -37,26 +37,7 @@ func TestCurrentVersion(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
version = tt.version
- r, w, _ := os.Pipe()
- oldStdout := os.Stdout
- os.Stdout = w
-
- currentVersion()
-
- // Close the writer and restore stdout
- err := w.Close()
- if err != nil {
- t.Fatalf("Failed to close pipe: %v", err)
- }
- os.Stdout = oldStdout
-
- // Read the output from the pipe
- var buf bytes.Buffer
- if _, err := buf.ReadFrom(r); err != nil {
- t.Fatalf("Failed to read from pipe: %v", err)
- }
-
- got := buf.String()
+ got := currentVersion()
if got != tt.expectedOutput {
t.Errorf("Expected %q, got %q", tt.expectedOutput, got)
}
@@ -129,8 +110,6 @@ func TestRunCLI_VariousCommands(t *testing.T) {
expected int
}{
{"Invalid command", []string{"foobar"}, 1},
- {"Latest flag long", []string{"--latest"}, 0},
- {"Latest flag short", []string{"-l"}, 0},
{"Version flag long", []string{"--version"}, 0},
{"Version flag short", []string{"-v"}, 0},
{"Search command with invalid args", []string{"search", "pokemon", "extra-arg"}, 1},
diff --git a/cmd/natures/natures.go b/cmd/natures/natures.go
index a2805624..377d80cd 100644
--- a/cmd/natures/natures.go
+++ b/cmd/natures/natures.go
@@ -37,8 +37,9 @@ func NaturesCommand(args []string) (string, error) {
output.WriteString("Natures affect the growth of a Pokémon.\n" +
"Each nature increases one of its stats by 10% and decreases one by 10%.\n" +
- "Five natures increase and decrease the same stat and therefore have no effect.\n\n" +
- styling.StyleBold.Render("Nature Chart:") + "\n")
+ "Five natures increase and decrease the same stat and therefore have no effect.\n\n")
+ output.WriteString(styling.StyleBold.Render("Nature Chart:"))
+ output.WriteString("\n")
chart := [][]string{
{" ", styling.Red.Render("-Attack"), styling.Red.Render("-Defense"), styling.Red.Render("-Sp. Atk"), styling.Red.Render("-Sp. Def"), styling.Red.Render("Speed")},
@@ -60,7 +61,15 @@ func NaturesCommand(args []string) (string, error) {
Padding(0, 1)
})
- output.WriteString(t.Render() + "\n")
+ output.WriteString(t.Render())
+ output.WriteString("\n")
+
+ deprecationWarning := styling.WarningBorder.Render(
+ styling.WarningColor.Render("⚠ Warning!"),
+ "\nThe natures command is deprecated\nand will be removed in v2.\n\nIt will move to a flag under the\nnew mechanics command. ",
+ )
+ output.WriteString("\n")
+ output.WriteString(deprecationWarning)
return output.String(), nil
}
diff --git a/cmd/pokemon/pokemon.go b/cmd/pokemon/pokemon.go
index c7413015..3abbbd94 100644
--- a/cmd/pokemon/pokemon.go
+++ b/cmd/pokemon/pokemon.go
@@ -116,8 +116,8 @@ func PokemonCommand(args []string) (string, error) {
flagFunc func(io.Writer, string, string) error
}{
{*pf.Abilities || *pf.ShortAbilities, flags.AbilitiesFlag},
- {*pf.Defense || *pf.ShortDefense, flags.DefenseFlag},
- {*pf.Move || *pf.ShortMove, flags.MovesFlag},
+ {*pf.Defenses || *pf.ShortDefenses, flags.DefenseFlag},
+ {*pf.Moves || *pf.ShortMoves, flags.MovesFlag},
{*pf.Stats || *pf.ShortStats, flags.StatsFlag},
{*pf.Types || *pf.ShortTypes, flags.TypesFlag},
}
diff --git a/cmd/search/model_input.go b/cmd/search/model_input.go
index 2a3b67ae..fc8e09fe 100644
--- a/cmd/search/model_input.go
+++ b/cmd/search/model_input.go
@@ -40,7 +40,7 @@ func UpdateInput(msg tea.Msg, m model) (tea.Model, tea.Cmd) {
// Call PokéAPI
result, err := query(endpoint, searchTerm)
if err != nil {
- fmt.Printf("Error fetching search results: %v", err)
+ m.WarningMessage = utils.FormatError(fmt.Sprintf("Error fetching search results: %v", err))
return m, nil
}
diff --git a/cmd/tcg/tcg.go b/cmd/tcg/tcg.go
index 1fca5412..b8e3aa19 100644
--- a/cmd/tcg/tcg.go
+++ b/cmd/tcg/tcg.go
@@ -10,6 +10,7 @@ import (
"github.com/digitalghost-dev/poke-cli/cmd/utils"
"github.com/digitalghost-dev/poke-cli/connections"
"github.com/digitalghost-dev/poke-cli/flags"
+ "github.com/digitalghost-dev/poke-cli/styling"
)
func TcgCommand(args []string) (string, error) {
@@ -89,6 +90,12 @@ func TcgCommand(args []string) (string, error) {
return "", err
}
+ deprecationWarning := styling.WarningBorder.Render(
+ styling.WarningColor.Render("⚠ Warning!"),
+ "\nThe tcg command is deprecated\nand will be removed in v2.\n\nIt will be renamed to a new\n'comp' command. ",
+ )
+ output.WriteString(deprecationWarning)
+
return output.String(), nil
}
diff --git a/cmd/types/damage_table.go b/cmd/types/damage_table.go
index 1e931e93..8638305c 100644
--- a/cmd/types/damage_table.go
+++ b/cmd/types/damage_table.go
@@ -13,11 +13,11 @@ import (
"golang.org/x/text/language"
)
-// DamageTable Function to display type details after a type is selected
-func DamageTable(typesName string, endpoint string) error {
+// DamageTable Function to build type details after a type is selected
+func DamageTable(typesName string, endpoint string) (string, error) {
typesStruct, typeName, err := connections.TypesApiCall(endpoint, typesName, connections.APIURL)
if err != nil {
- return err
+ return "", err
}
// Setting up variables to style the list
@@ -34,9 +34,11 @@ func DamageTable(typesName string, endpoint string) error {
selectedType := cases.Title(language.English).String(typeName)
coloredType := lipgloss.NewStyle().Foreground(lipgloss.Color(styling.GetTypeColor(typeName))).Render(selectedType)
- fmt.Printf("You selected the %s type.\nNumber of Pokémon with type: %d\nNumber of moves with type: %d\n", coloredType, len(typesStruct.Pokemon), len(typesStruct.Moves))
- fmt.Println("----------")
- fmt.Println(styling.StyleBold.Render("Damage Chart:"))
+ var out strings.Builder
+ fmt.Fprintf(&out, "You selected the %s type.\nNumber of Pokémon with type: %d\nNumber of moves with type: %d\n", coloredType, len(typesStruct.Pokemon), len(typesStruct.Moves))
+ out.WriteString("----------\n")
+ out.WriteString(styling.StyleBold.Render("Damage Chart:"))
+ out.WriteString("\n")
physicalWidth, _, _ := term.GetSize(uintptr(int(os.Stdout.Fd())))
doc := strings.Builder{}
@@ -100,8 +102,8 @@ func DamageTable(typesName string, endpoint string) error {
docStyle = docStyle.MaxWidth(physicalWidth)
}
- // Print the rendered document
- fmt.Println(docStyle.Render(doc.String()))
+ // Append the rendered document
+ out.WriteString(docStyle.Render(doc.String()))
- return nil
+ return out.String(), nil
}
diff --git a/cmd/types/damage_table_test.go b/cmd/types/damage_table_test.go
index ae1b9cc3..ecac0a1d 100644
--- a/cmd/types/damage_table_test.go
+++ b/cmd/types/damage_table_test.go
@@ -1,8 +1,6 @@
package types
import (
- "bytes"
- "os"
"strings"
"testing"
@@ -10,33 +8,12 @@ import (
)
func TestDamageTable(t *testing.T) {
- originalStdout := os.Stdout
-
- r, w, err := os.Pipe()
+ output, err := DamageTable("fire", "type")
if err != nil {
- t.Fatalf("Failed to create pipe: %v", err)
- }
-
- os.Stdout = w
-
- if err := DamageTable("fire", "type"); err != nil {
t.Fatalf("DamageTable returned an error: %v", err)
}
+ output = styling.StripANSI(output)
- err = w.Close()
- if err != nil {
- t.Fatalf("Failed to close pipe: %v", err)
- }
- os.Stdout = originalStdout
-
- var buf bytes.Buffer
- _, err = buf.ReadFrom(r)
- if err != nil {
- t.Fatalf("Failed to read from pipe: %v", err)
- }
- output := styling.StripANSI(buf.String())
-
- // Step 7: Assert the output contains expected strings
if !strings.Contains(output, "You selected the Fire type.") {
t.Errorf("Expected output to contain Fire type header, got:\n%s", output)
}
@@ -47,7 +24,7 @@ func TestDamageTable(t *testing.T) {
}
func TestDamageTable_TypeNotFound(t *testing.T) {
- err := DamageTable("notatype", "type")
+ _, err := DamageTable("notatype", "type")
if err == nil {
t.Fatal("expected an error for unknown type, got nil")
}
diff --git a/cmd/types/types.go b/cmd/types/types.go
index 3f8b078a..4854cd38 100644
--- a/cmd/types/types.go
+++ b/cmd/types/types.go
@@ -39,10 +39,12 @@ func TypesCommand(args []string) (string, error) {
}
const endpoint = "type"
- if err := runTypeSelectionTable(endpoint); err != nil {
+ chart, err := runTypeSelectionTable(endpoint)
+ if err != nil {
output.WriteString(err.Error())
return output.String(), err
}
+ output.WriteString(chart)
return output.String(), nil
}
@@ -128,19 +130,17 @@ func createTypeSelectionTable() model {
return model{table: tbl}
}
-func runTypeSelectionTable(endpoint string) error {
+func runTypeSelectionTable(endpoint string) (string, error) {
m := createTypeSelectionTable()
programModel, err := tea.NewProgram(m).Run()
if err != nil {
- return fmt.Errorf("error running program: %w", err)
+ return "", fmt.Errorf("error running program: %w", err)
}
if finalModel, ok := programModel.(model); ok && finalModel.selectedOption != "" {
- if err := DamageTable(strings.ToLower(finalModel.selectedOption), endpoint); err != nil {
- return err
- }
+ return DamageTable(strings.ToLower(finalModel.selectedOption), endpoint)
}
- return nil
+ return "", nil
}
diff --git a/docs/installation.md b/docs/installation.md
index bf6eb6a1..d2211051 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -63,11 +63,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
3. Choose how to interact with the container:
* Run a single command and exit:
```console
- docker run --rm -it digitalghostdev/poke-cli:v1.10.2 [subcommand] [flag]
+ docker run --rm -it digitalghostdev/poke-cli:v1.10.3 [subcommand] [flag]
```
* Enter the container and use its shell:
```console
- docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.2 -c "cd /app && exec sh"
+ docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.3 -c "cd /app && exec sh"
# placed into the /app directory, run the program with './poke-cli'
# example: ./poke-cli ability swift-swim
```
@@ -77,13 +77,13 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
The `card` command renders TCG card images using your terminal's graphics protocol. When running inside Docker, pass your terminal's environment variables so image rendering works correctly:
```console
# Kitty
- docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.2 card
+ docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.3 card
# WezTerm, iTerm2, Ghostty, Konsole, Rio, Tabby
- docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.2 card
+ docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.3 card
# Windows Terminal (Sixel)
- docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.2 card
+ docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.3 card
```
If your terminal is not listed above, image rendering is not supported inside Docker.
diff --git a/flags/pokemonflagset.go b/flags/pokemonflagset.go
index d0d34d6c..3686965e 100644
--- a/flags/pokemonflagset.go
+++ b/flags/pokemonflagset.go
@@ -35,12 +35,12 @@ type PokemonFlags struct {
FlagSet *flag.FlagSet
Abilities *bool
ShortAbilities *bool
- Defense *bool
- ShortDefense *bool
+ Defenses *bool
+ ShortDefenses *bool
Image *string
ShortImage *string
- Move *bool
- ShortMove *bool
+ Moves *bool
+ ShortMoves *bool
Stats *bool
ShortStats *bool
Types *bool
@@ -69,14 +69,14 @@ func SetupPokemonFlagSet() *PokemonFlags {
pf.Abilities = pf.FlagSet.Bool("abilities", false, "Print the Pokémon's abilities")
pf.ShortAbilities = pf.FlagSet.Bool("a", false, "Print the Pokémon's abilities")
- pf.Defense = pf.FlagSet.Bool("defense", false, "Print the Pokémon's type defenses")
- pf.ShortDefense = pf.FlagSet.Bool("d", false, "Print the Pokémon's type defenses")
+ pf.Defenses = pf.FlagSet.Bool("defense", false, "Print the Pokémon's type defenses")
+ pf.ShortDefenses = pf.FlagSet.Bool("d", false, "Print the Pokémon's type defenses")
pf.Image = pf.FlagSet.String("image", "", "Print the Pokémon's default sprite")
pf.ShortImage = pf.FlagSet.String("i", "", "Print the Pokémon's default sprite")
- pf.Move = pf.FlagSet.Bool("moves", false, "Print the Pokémon's learnable moves")
- pf.ShortMove = pf.FlagSet.Bool("m", false, "Print the Pokémon's learnable moves")
+ pf.Moves = pf.FlagSet.Bool("moves", false, "Print the Pokémon's learnable moves")
+ pf.ShortMoves = pf.FlagSet.Bool("m", false, "Print the Pokémon's learnable moves")
pf.Stats = pf.FlagSet.Bool("stats", false, "Print the Pokémon's base stats")
pf.ShortStats = pf.FlagSet.Bool("s", false, "Print the Pokémon's base stats")
@@ -395,21 +395,17 @@ func ImageFlag(w io.Writer, endpoint string, pokemonName string, size string) er
imageResp, err := pokemonSpriteHTTPClient.Get(pokemonStruct.Sprites.FrontDefault)
if err != nil {
- fmt.Println("Error downloading sprite image:", err)
- return err
+ return fmt.Errorf("error downloading sprite image: %w", err)
}
defer imageResp.Body.Close()
if imageResp.StatusCode != http.StatusOK {
- err := fmt.Errorf("unexpected sprite response status: %d", imageResp.StatusCode)
- fmt.Println("Error downloading sprite image:", err)
- return err
+ return fmt.Errorf("unexpected sprite response status: %d", imageResp.StatusCode)
}
img, err := imaging.Decode(io.LimitReader(imageResp.Body, maxPokemonSpriteBytes))
if err != nil {
- fmt.Println("Error decoding image:", err)
- return err
+ return fmt.Errorf("error decoding image: %w", err)
}
imgStr := ToString(dimensions[0], dimensions[1], img)
diff --git a/flags/pokemonflagset_test.go b/flags/pokemonflagset_test.go
index 35058260..10b4f886 100644
--- a/flags/pokemonflagset_test.go
+++ b/flags/pokemonflagset_test.go
@@ -25,12 +25,12 @@ func TestSetupPokemonFlagSet(t *testing.T) {
}{
{pf.Abilities, false, "Abilities flag should be 'abilities'"},
{pf.ShortAbilities, false, "Short abilities flag should be 'a'"},
- {pf.Defense, false, "Defense flag should be 'defense'"},
- {pf.ShortDefense, false, "Short Defense flag should be 'd'"},
+ {pf.Defenses, false, "Defenses flag should be 'defense'"},
+ {pf.ShortDefenses, false, "Short Defenses flag should be 'd'"},
{pf.Image, "", "Image flag default value should be 'md'"},
{pf.ShortImage, "", "Short image flag default value should be 'md'"},
- {pf.Move, false, "Move flag default value should be 'moves'"},
- {pf.ShortMove, false, "Short move flag default value should be 'm'"},
+ {pf.Moves, false, "Moves flag default value should be 'moves'"},
+ {pf.ShortMoves, false, "Short moves flag default value should be 'm'"},
{pf.Types, false, "Types flag should be 'types'"},
{pf.Stats, false, "Stats flag should be 'stats'"},
{pf.ShortStats, false, "Short stats flag should be 's'"},
diff --git a/flags/version.go b/flags/version.go
index 2c2249d3..ddca5e99 100644
--- a/flags/version.go
+++ b/flags/version.go
@@ -9,9 +9,12 @@ import (
"net/http"
"net/url"
"os"
+ "strconv"
"strings"
+ "time"
"charm.land/lipgloss/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/utils"
"github.com/digitalghost-dev/poke-cli/connections"
"github.com/digitalghost-dev/poke-cli/styling"
)
@@ -60,7 +63,15 @@ func latestReleaseFromURL(output *strings.Builder, releaseURL string, client *ht
}
}
- response, err := client.Get(parsedURL.String())
+ req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
+ if err != nil {
+ err = fmt.Errorf("error creating request: %w", err)
+ fmt.Fprintln(output, err)
+ return err
+ }
+ req.Header.Set("User-Agent", "poke-cli")
+
+ response, err := client.Do(req)
if err != nil {
err = fmt.Errorf("error fetching data: %w", err)
fmt.Fprintln(output, err)
@@ -69,7 +80,7 @@ func latestReleaseFromURL(output *strings.Builder, releaseURL string, client *ht
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
- err := fmt.Errorf("unexpected GitHub response status: %d", response.StatusCode)
+ err := gitHubStatusError(response)
fmt.Fprintln(output, err)
return err
}
@@ -116,3 +127,19 @@ func latestReleaseFromURL(output *strings.Builder, releaseURL string, client *ht
return nil
}
+
+func gitHubStatusError(response *http.Response) error {
+ rateLimited := (response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusTooManyRequests) &&
+ response.Header.Get("X-RateLimit-Remaining") == "0"
+ if !rateLimited {
+ return fmt.Errorf("unexpected GitHub response status: %d", response.StatusCode)
+ }
+
+ msg := "GitHub API rate limit reached (60 requests/hour for unauthenticated requests)."
+ if reset := response.Header.Get("X-RateLimit-Reset"); reset != "" {
+ if secs, err := strconv.ParseInt(reset, 10, 64); err == nil {
+ msg += "\nTry again after " + time.Unix(secs, 0).Format("3:04 PM") + "."
+ }
+ }
+ return errors.New(utils.FormatError(msg))
+}
diff --git a/flags/version_test.go b/flags/version_test.go
index 3e6bf101..91678866 100644
--- a/flags/version_test.go
+++ b/flags/version_test.go
@@ -4,8 +4,10 @@ import (
"io"
"net/http"
"os"
+ "strconv"
"strings"
"testing"
+ "time"
"github.com/digitalghost-dev/poke-cli/cmd/utils"
"github.com/digitalghost-dev/poke-cli/styling"
@@ -149,3 +151,50 @@ func TestLatestReleaseFromURL(t *testing.T) {
})
}
}
+
+func TestLatestReleaseFromURL_RateLimited(t *testing.T) {
+ reset := time.Now().Add(30 * time.Minute).Unix()
+ client := &http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ header := make(http.Header)
+ header.Set("X-RateLimit-Remaining", "0")
+ header.Set("X-RateLimit-Reset", strconv.FormatInt(reset, 10))
+ return &http.Response{
+ StatusCode: http.StatusForbidden,
+ Header: header,
+ Body: io.NopCloser(strings.NewReader(`{"message":"rate limit exceeded"}`)),
+ Request: req,
+ }, nil
+ }),
+ }
+
+ var output strings.Builder
+ err := latestReleaseFromURL(&output, "https://api.github.com/repos/digitalghost-dev/poke-cli/releases/latest", client)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "rate limit reached")
+ assert.Contains(t, err.Error(), "Try again after")
+ // A rate-limit response should not fall back to the bare status message.
+ assert.NotContains(t, err.Error(), "unexpected GitHub response status")
+}
+
+func TestLatestReleaseFromURL_SetsUserAgent(t *testing.T) {
+ var gotUserAgent string
+ client := &http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ gotUserAgent = req.Header.Get("User-Agent")
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.2.3"}`)),
+ Request: req,
+ }, nil
+ }),
+ }
+
+ var output strings.Builder
+ err := latestReleaseFromURL(&output, "https://api.github.com/repos/digitalghost-dev/poke-cli/releases/latest", client)
+
+ require.NoError(t, err)
+ assert.Equal(t, "poke-cli", gotUserAgent)
+}
diff --git a/go.mod b/go.mod
index d88d9630..98756fb4 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/digitalghost-dev/poke-cli
-go 1.25.9
+go 1.25.10
require (
charm.land/bubbles/v2 v2.1.0
@@ -16,9 +16,9 @@ require (
github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4
github.com/schollz/closestmatch v2.1.0+incompatible
github.com/stretchr/testify v1.11.1
- golang.org/x/image v0.33.0
+ golang.org/x/image v0.38.0
golang.org/x/term v0.42.0
- golang.org/x/text v0.31.0
+ golang.org/x/text v0.35.0
modernc.org/sqlite v1.39.1
)
@@ -57,7 +57,7 @@ require (
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
- golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
diff --git a/go.sum b/go.sum
index c4d181a0..93b4e6de 100644
--- a/go.sum
+++ b/go.sum
@@ -107,12 +107,12 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
-golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
-golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
-golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
+golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
@@ -120,10 +120,10 @@ golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/nfpm.yaml b/nfpm.yaml
index 56f5c4c9..f9ac9265 100644
--- a/nfpm.yaml
+++ b/nfpm.yaml
@@ -1,7 +1,7 @@
name: "poke-cli"
arch: "arm64"
platform: "linux"
-version: "v1.10.2"
+version: "v1.10.3"
section: "default"
version_schema: semver
maintainer: "Christian S"
diff --git a/testdata/natures.golden b/testdata/natures.golden
index ef735f45..8ddc0a8c 100644
--- a/testdata/natures.golden
+++ b/testdata/natures.golden
@@ -16,3 +16,12 @@ Nature Chart:
├──────────┼─────────┼──────────┼──────────┼──────────┼─────────┤
│ Speed │ Timid │ Hasty │ Jolly │ Naive │ Serious │
└──────────┴─────────┴──────────┴──────────┴──────────┴─────────┘
+
+╭─────────────────────────────────╮
+│⚠ Warning! │
+│The natures command is deprecated│
+│and will be removed in v2. │
+│ │
+│It will move to a flag under the │
+│new mechanics command. │
+╰─────────────────────────────────╯
\ No newline at end of file
diff --git a/web/pyproject.toml b/web/pyproject.toml
index d53fd457..6d832061 100644
--- a/web/pyproject.toml
+++ b/web/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "web"
-version = "v1.10.2"
+version = "v1.10.3"
description = "Streamlit dashboard for browsing and visualizing Pokémon TCG tournament standings and results."
readme = "README.md"
requires-python = ">=3.12"