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 @@ pokemon-logo

version-label - docker-image-size + docker-image-size ci-status-badge
@@ -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"