diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fe9a04..9e9203a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ on: - main env: - VERSION_NUMBER: 'v1.10.0' + VERSION_NUMBER: 'v1.10.1' DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli' AWS_REGION: 'us-west-2' diff --git a/.github/workflows/python_testing.yml b/.github/workflows/python_testing.yml index 854949c..cb44564 100644 --- a/.github/workflows/python_testing.yml +++ b/.github/workflows/python_testing.yml @@ -59,7 +59,7 @@ jobs: uses: astral-sh/setup-uv@v7 - name: Install dependencies - run: uv sync --dev + run: uv sync --all-groups - name: Run tests run: uv run pytest app_test.py -v diff --git a/.gitignore b/.gitignore index 7633e31..e31d41a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.so *.dylib *.DS_Store +**/.DS_Store *.idea .dccache dist/ @@ -38,6 +39,8 @@ web/.coverage card_data/.venv __pycache__/ .ruff_cache/ +.coverage +htmlcov/ # Terraform .terraformrc @@ -56,23 +59,17 @@ logs/ target/ +# Card Data card_data/infrastructure/supabase/access-token /card_data/infrastructure/supabase/access-token **/.terraform/ - card_data/.tmp*/** - card_data/pipelines/poke_cli_dbt/.user.yml /card_data/supabase/ /card_data/sample_scripts/ - card_data/~/ card_data/storage/ -/.claude/ -CLAUDE.md -REFACTORING.md /card_data/.codspeed/ -/.ai/ # Version management VERSION @@ -80,4 +77,13 @@ version-bump.sh # Testing libraries .codspeed/ -.pytest_cache/ \ No newline at end of file +.pytest_cache/ + +# AI +/.claude/ +CLAUDE.md +REFACTORING.md +AGENTS.md +.agents/ +.codex/ +/.ai/ \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index 53c71ab..a796c28 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -14,7 +14,7 @@ builds: - windows - darwin ldflags: - - -s -w -X main.version=v1.10.0 + - -s -w -X main.version=v1.10.1 archives: - formats: [ 'zip' ] diff --git a/Dockerfile b/Dockerfile index 01050a4..13dd81c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN go mod download COPY . . -RUN go build -ldflags "-X main.version=v1.10.0" -o poke-cli . +RUN go build -ldflags "-X main.version=v1.10.1" -o poke-cli . # build 2 FROM --platform=$BUILDPLATFORM alpine:3.23 diff --git a/README.md b/README.md index fbc84d8..23ab118 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ pokemon-logo

version-label - docker-image-size + docker-image-size ci-status-badge
@@ -99,11 +99,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.0 [subcommand] [flag] + docker run --rm -it digitalghostdev/poke-cli:v1.10.1 [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.0 -c "cd /app && exec sh" + docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.1 -c "cd /app && exec sh" # placed into the /app directory, run the program with './poke-cli' # example: ./poke-cli ability swift-swim ``` @@ -112,13 +112,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.0 card +> docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.1 card > > # WezTerm, iTerm2, Ghostty, Konsole, Rio, Tabby -> docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.0 card +> docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.1 card > > # Windows Terminal (Sixel) -> docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.0 card +> docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.1 card > ``` > If your terminal is not listed above, image rendering is not supported inside Docker. @@ -215,15 +215,16 @@ Below is a list of the planned/completed commands and flags: - [x] add sun & moon data - [ ] add x & y data - [x] `item`: get data about an item. -- [x] `move`: get data about a move. +- [ ] `move`: get data about a move. - [ ] `-p | --pokemon`: display Pokémon that learn this move. - [x] `natures`: get data about natures. -- [x] `pokemon`: get data about a Pokémon. +- [ ] `pokemon`: get data about a Pokémon. - [x] `-a | --abilities`: display the Pokémon's abilities. + - [ ] `-c | --cry`: play the Pokémon's cry. - [x] `-d | --defense`: display the Pokémon's type defences. - [x] `-i | --image`: display a pixel image of the Pokémon. - - [x] `-s | --stats`: display the Pokémon's base stats. - [x] `-m | --moves`: display learnable moves. + - [x] `-s | --stats`: display the Pokémon's base stats. - [ ] `search`: search for a resource - [x] `ability` - [ ] `berry` diff --git a/card_data/README.md b/card_data/README.md index 2ffd2d9..267b549 100644 --- a/card_data/README.md +++ b/card_data/README.md @@ -7,19 +7,32 @@ and decided to process all the data myself, load it into Supabase, and read from ## Data Architecture Runs at 2:00PM PST daily. -![data_diagram](data_infrastructure_v2.png) +![data_diagram](data_infrastructure_diagram.png) 1. TCGPlayer pricing data and TCGDex card data are called and processed through a data pipeline orchestrated by Dagster and hosted on AWS. + - Dagster runs on an EC2 instance. + - Dagster metadata is stored separately in RDS. + - The pricing pipeline is scheduled with cron: `0 14 * * *`. + - Tournament standings data is also pulled from Limitless. 2. When the pipeline starts, Pydantic validates the incoming API data against a pre-defined schema, ensuring the data types match the expected structure. + - Invalid or unexpected payloads fail early before data is loaded downstream. 3. Polars is used to create DataFrames. + - DataFrames are used to clean, normalize, and prepare records for database loading. 4. The data is loaded into a Supabase staging schema. + - The staging schema acts as the raw/validated landing area before production tables are built. 5. Soda data quality checks are performed. + - Checks validate expectations such as row counts, required columns, missing values, duplicate keys, and URL formats. -6. `dbt` runs and builds the final tables in a Supabase production schema. +6. dbt runs tests and builds the final tables in a Supabase production schema. + - dbt transforms staged data into the final public-facing models. + - The production schema powers TCG/card/tournament queries. 7. Users are then able to query the `pokeapi.co` or supabase APIs for either video game or trading card data, respectively. + - The CLI uses PokéAPI for video game data. + - The CLI and Streamlit web app use Supabase for TCG data. + - Dagster run status is sent through an n8n webhook for Discord notifications. diff --git a/card_data/data_infrastructure_diagram.png b/card_data/data_infrastructure_diagram.png new file mode 100644 index 0000000..21a871d Binary files /dev/null and b/card_data/data_infrastructure_diagram.png differ diff --git a/card_data/data_infrastructure_v2.png b/card_data/data_infrastructure_v2.png deleted file mode 100644 index cae98e9..0000000 Binary files a/card_data/data_infrastructure_v2.png and /dev/null differ diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml index e5544fa..181a825 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: 'v1.10.0' +version: '1.10.1' profile: 'poke_cli_dbt' diff --git a/card_data/pyproject.toml b/card_data/pyproject.toml index db27956..40203d2 100644 --- a/card_data/pyproject.toml +++ b/card_data/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "card-data" -version = "v1.10.0" +version = "v1.10.1" description = "File directory to store all data related processes for the Pokémon TCG." readme = "README.md" requires-python = ">=3.12" diff --git a/cmd/berry/berry.go b/cmd/berry/berry.go index b0edb7d..b2c120b 100644 --- a/cmd/berry/berry.go +++ b/cmd/berry/berry.go @@ -3,7 +3,6 @@ package berry import ( "flag" "fmt" - "log" "os" "strings" @@ -141,7 +140,7 @@ func tableGeneration() error { ORDER BY name`) if err != nil { - log.Fatalf("Failed to get berry names: %v", err) + return fmt.Errorf("failed to get berry names: %w", err) } rows := make([]table.Row, len(namesList)) diff --git a/cmd/pokemon/pokemon.go b/cmd/pokemon/pokemon.go index 1e7da27..eb3ac37 100644 --- a/cmd/pokemon/pokemon.go +++ b/cmd/pokemon/pokemon.go @@ -29,10 +29,11 @@ func PokemonCommand() (string, error) { ShowHyphenHint: true, Flags: []utils.FlagHelp{ {Short: "-a", Long: "--abilities", Description: "Prints the Pokémon's abilities."}, + {Short: "-d", Long: "--defense", Description: "Prints the Pokémon's type defenses."}, {Short: "-i=xx", Long: "--image=xx", Description: "Prints out the Pokémon's default sprite.\n\t " + styling.StyleItalic.Render("options: [sm, md, lg]")}, {Short: "-m", Long: "--moves", Description: "Prints the Pokémon's learnable moves."}, {Short: "-s", Long: "--stats", Description: "Prints the Pokémon's base stats."}, - {Short: "-t", Long: "--types", Description: styling.ErrorColor.Render("Deprecated. Types are included with each Pokémon.")}, + {Short: "-t", Long: "--types", Description: styling.ErrorColor.Render("Deprecated. Typing is included by default.")}, }, }, ), diff --git a/cmd/speed/speed.go b/cmd/speed/speed.go index 1f360bd..906334c 100644 --- a/cmd/speed/speed.go +++ b/cmd/speed/speed.go @@ -4,7 +4,6 @@ import ( "errors" "flag" "fmt" - "log" "math" "os" "strconv" @@ -260,7 +259,7 @@ func formula() (string, error) { speedStageInt, err := strconv.Atoi(pokemon.SpeedStage) if err != nil { - log.Fatalf("Invalid SpeedStage: %v", err) + return "", fmt.Errorf("invalid SpeedStage: %w", err) } stageMultiplier := stageMultipliers[speedStageInt] diff --git a/cmd/speed/speed_test.go b/cmd/speed/speed_test.go index 16a604d..64db11e 100644 --- a/cmd/speed/speed_test.go +++ b/cmd/speed/speed_test.go @@ -208,6 +208,20 @@ func TestFormula(t *testing.T) { expectedSpeed: "1035", wantError: false, }, + { + name: "Invalid SpeedStage returns error", + pokemonDetails: PokemonDetails{ + Name: "pikachu", + SpeedStage: "abc", + Nature: "0%", + Level: "50", + Modifier: []string{}, + Ability: "None", + SpeedEV: "0", + SpeedIV: "0", + }, + wantError: true, + }, } for _, tt := range tests { diff --git a/cmd/utils/errors.go b/cmd/utils/errors.go index 3f439be..a13b104 100644 --- a/cmd/utils/errors.go +++ b/cmd/utils/errors.go @@ -8,3 +8,26 @@ func FormatError(message string) string { "\n"+message, ) } + +func FormatNotFoundError(resourceType string) string { + return FormatError(resourceType + " not found.\n• Perhaps a typo?\n• Missing a hyphen instead of a space?") +} + +func FormatNetworkError(resourceType string) string { + return FormatError("Could not reach " + resourceType + " data.\nCheck your connection and try again.") +} + +func FormatServerError(resourceType string) string { + return FormatError(resourceType + " data source returned a server error.\nPlease try again later.") +} + +func FormatUnexpectedDataError(resourceType string) string { + return FormatError(resourceType + " data source returned data in an unexpected format.") +} + +func FormatFetchError(resourceType string, err error) string { + if err == nil { + return FormatError("Could not fetch " + resourceType + " data.") + } + return FormatError("Could not fetch " + resourceType + " data.\n" + err.Error()) +} diff --git a/cmd/utils/errors_test.go b/cmd/utils/errors_test.go new file mode 100644 index 0000000..9ee5bad --- /dev/null +++ b/cmd/utils/errors_test.go @@ -0,0 +1,76 @@ +package utils + +import ( + "errors" + "testing" + + "github.com/digitalghost-dev/poke-cli/styling" + "github.com/stretchr/testify/assert" +) + +func TestFormatResourceErrors(t *testing.T) { + tests := []struct { + name string + format func() string + contains []string + }{ + { + name: "not found", + format: func() string { return FormatNotFoundError("Pokémon") }, + contains: []string{ + "Pokémon not found.", + "Perhaps a typo?", + "Missing a hyphen instead of a space?", + }, + }, + { + name: "network", + format: func() string { return FormatNetworkError("Pokémon") }, + contains: []string{ + "Could not reach Pokémon data.", + "Check your connection and try again.", + }, + }, + { + name: "server", + format: func() string { return FormatServerError("Pokémon") }, + contains: []string{ + "Pokémon data source returned a server error.", + "Please try again later.", + }, + }, + { + name: "unexpected data", + format: func() string { return FormatUnexpectedDataError("Pokémon") }, + contains: []string{ + "Pokémon data source returned data in an unexpected format.", + }, + }, + { + name: "fetch with error", + format: func() string { return FormatFetchError("Pokémon", errors.New("request failed")) }, + contains: []string{ + "Could not fetch Pokémon data.", + "request failed", + }, + }, + { + name: "fetch with nil error", + format: func() string { return FormatFetchError("Pokémon", nil) }, + contains: []string{ + "Could not fetch Pokémon data.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := styling.StripANSI(tt.format()) + + assert.Contains(t, output, "✖ Error!") + for _, expected := range tt.contains { + assert.Contains(t, output, expected) + } + }) + } +} diff --git a/connections/connection.go b/connections/connection.go index 160ff0f..3fc8f29 100644 --- a/connections/connection.go +++ b/connections/connection.go @@ -22,20 +22,55 @@ type EndpointResource interface { GetResourceName() string } -func FetchEndpoint[T EndpointResource](endpoint, resourceName, baseURL, resourceType string) (T, string, error) { +type HTTPStatusError struct { + StatusCode int + URL string +} + +func (e HTTPStatusError) Error() string { + return fmt.Sprintf("non-200 response: %d", e.StatusCode) +} + +func fetchEndpoint[T EndpointResource](endpoint, resourceName, baseURL, resourceType string) (T, string, error) { var zero T fullURL := baseURL + endpoint + "/" + resourceName var result T err := ApiCallSetup(fullURL, &result, false) - if err != nil { - return zero, "", fmt.Errorf("%s", utils.FormatError(resourceType+" not found.\n• Perhaps a typo?\n• Missing a hyphen instead of a space?")) + return zero, "", formatEndpointError(resourceType, err) } return result, result.GetResourceName(), nil } +func formatEndpointError(resourceType string, err error) error { + var statusErr HTTPStatusError + if errors.As(err, &statusErr) { + switch { + case statusErr.StatusCode == http.StatusNotFound: + return fmt.Errorf("%s", utils.FormatNotFoundError(resourceType)) + case statusErr.StatusCode >= http.StatusInternalServerError: + return fmt.Errorf("%s", utils.FormatServerError(resourceType)) + default: + return fmt.Errorf("%s", utils.FormatFetchError(resourceType, err)) + } + } + + var urlErr *url.Error + if errors.As(err, &urlErr) { + return fmt.Errorf("%s", utils.FormatNetworkError(resourceType)) + } + + var syntaxErr *json.SyntaxError + var typeErr *json.UnmarshalTypeError + if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) { + return fmt.Errorf("%s", utils.FormatUnexpectedDataError(resourceType)) + } + + return fmt.Errorf("%s", utils.FormatFetchError(resourceType, err)) +} + // ApiCallSetup Helper function to handle API calls and JSON unmarshalling func ApiCallSetup(rawURL string, target interface{}, skipHTTPSCheck bool) error { parsedURL, err := url.Parse(rawURL) @@ -59,7 +94,7 @@ func ApiCallSetup(rawURL string, target interface{}, skipHTTPSCheck bool) error defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("non-200 response: %d", resp.StatusCode) + return HTTPStatusError{StatusCode: resp.StatusCode, URL: rawURL} } body, err := io.ReadAll(resp.Body) @@ -76,27 +111,27 @@ func ApiCallSetup(rawURL string, target interface{}, skipHTTPSCheck bool) error } func AbilityApiCall(endpoint, abilityName, baseURL string) (structs.AbilityJSONStruct, string, error) { - return FetchEndpoint[structs.AbilityJSONStruct](endpoint, abilityName, baseURL, "Ability") + return fetchEndpoint[structs.AbilityJSONStruct](endpoint, abilityName, baseURL, "Ability") } func ItemApiCall(endpoint string, itemName string, baseURL string) (structs.ItemJSONStruct, string, error) { - return FetchEndpoint[structs.ItemJSONStruct](endpoint, itemName, baseURL, "Item") + return fetchEndpoint[structs.ItemJSONStruct](endpoint, itemName, baseURL, "Item") } func MoveApiCall(endpoint string, moveName string, baseURL string) (structs.MoveJSONStruct, string, error) { - return FetchEndpoint[structs.MoveJSONStruct](endpoint, moveName, baseURL, "Move") + return fetchEndpoint[structs.MoveJSONStruct](endpoint, moveName, baseURL, "Move") } func PokemonApiCall(endpoint string, pokemonName string, baseURL string) (structs.PokemonJSONStruct, string, error) { - return FetchEndpoint[structs.PokemonJSONStruct](endpoint, pokemonName, baseURL, "Pokémon") + return fetchEndpoint[structs.PokemonJSONStruct](endpoint, pokemonName, baseURL, "Pokémon") } func PokemonSpeciesApiCall(endpoint string, pokemonSpeciesName string, baseURL string) (structs.PokemonSpeciesJSONStruct, string, error) { - return FetchEndpoint[structs.PokemonSpeciesJSONStruct](endpoint, pokemonSpeciesName, baseURL, "PokémonSpecies") + return fetchEndpoint[structs.PokemonSpeciesJSONStruct](endpoint, pokemonSpeciesName, baseURL, "PokémonSpecies") } func TypesApiCall(endpoint string, typesName string, baseURL string) (structs.TypesJSONStruct, string, error) { - return FetchEndpoint[structs.TypesJSONStruct](endpoint, typesName, baseURL, "Type") + return fetchEndpoint[structs.TypesJSONStruct](endpoint, typesName, baseURL, "Type") } func CallTCGData(url string) ([]byte, error) { diff --git a/connections/connection_test.go b/connections/connection_test.go index 8da96ad..e96b99c 100644 --- a/connections/connection_test.go +++ b/connections/connection_test.go @@ -123,6 +123,38 @@ func TestAbilityApiCall(t *testing.T) { assert.Contains(t, err.Error(), "Ability not found", "Expected 'Ability not found' in error message") assert.Contains(t, err.Error(), "Perhaps a typo?", "Expected helpful suggestion in error message") }) + + t.Run("Server error does not return not found message", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Server Error", http.StatusInternalServerError) + })) + defer ts.Close() + + ability, name, err := AbilityApiCall("/ability", "unaware", ts.URL) + + require.Error(t, err) + assert.Equal(t, structs.AbilityJSONStruct{}, ability) + assert.Empty(t, name) + assert.Contains(t, err.Error(), "Ability data source returned a server error.") + assert.NotContains(t, err.Error(), "Ability not found") + }) + + t.Run("Malformed JSON does not return not found message", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("not-json")) + assert.NoError(t, err) + })) + defer ts.Close() + + ability, name, err := AbilityApiCall("/ability", "unaware", ts.URL) + + require.Error(t, err) + assert.Equal(t, structs.AbilityJSONStruct{}, ability) + assert.Empty(t, name) + assert.Contains(t, err.Error(), "Ability data source returned data in an unexpected format.") + assert.NotContains(t, err.Error(), "Ability not found") + }) } func TestItemApiCall(t *testing.T) { diff --git a/docs/Infrastructure_Guide/index.md b/docs/Infrastructure_Guide/index.md index ac76d3a..000ef5e 100644 --- a/docs/Infrastructure_Guide/index.md +++ b/docs/Infrastructure_Guide/index.md @@ -24,15 +24,32 @@ The VGC data simply calls one API. ## Data Infrastructure Diagram ![data_infrastructure_diagram](../assets/data_infrastructure_diagram.svg) -1. TCGPlayer pricing data and TCGDex card data are called and processed through a data pipeline orchestrated by Dagster -and hosted on AWS. -2. When the pipeline starts, Pydantic validates the incoming API data against a pre-defined schema, ensuring the data -types match the expected structure. +1. TCGPlayer pricing data and TCGDex card data are called and processed through a data pipeline orchestrated by Dagster and hosted on AWS. + - Dagster runs on an EC2 instance. + - Dagster metadata is stored separately in RDS. + - The pricing pipeline is scheduled with cron: `0 14 * * *`. + - Tournament standings data is also pulled from Limitless. + +2. When the pipeline starts, Pydantic validates the incoming API data against a pre-defined schema, ensuring the data types match the expected structure. + - Invalid or unexpected payloads fail early before data is loaded downstream. + 3. Polars is used to create DataFrames. + - DataFrames are used to clean, normalize, and prepare records for database loading. + 4. The data is loaded into a Supabase staging schema. + - The staging schema acts as the raw/validated landing area before production tables are built. + 5. Soda data quality checks are performed. -6. `dbt` runs tests and builds the final tables in a Supabase production schema. + - Checks validate expectations such as row counts, required columns, missing values, duplicate keys, and URL formats. + +6. dbt runs tests and builds the final tables in a Supabase production schema. + - dbt transforms staged data into the final public-facing models. + - The production schema powers TCG/card/tournament queries. + 7. Users are then able to query the `pokeapi.co` or `supabase` APIs for either video game or trading card data, respectively. + - The CLI uses PokéAPI for video game data. + - The CLI and Streamlit web app use Supabase for TCG data. + - Dagster run status is sent through an n8n webhook for Discord notifications. ## Tools & Services @@ -114,4 +131,4 @@ Below is a list of all the tools and services used in this project's infrastruct that's part of the learning process! Feedback and suggestions are always welcome! If you spot an issue or have ideas for improvement, - please open a [GitHub Issue](https://github.com/digitalghost-dev/poke-cli/issues). \ No newline at end of file + please open a [GitHub Issue](https://github.com/digitalghost-dev/poke-cli/issues). diff --git a/docs/assets/card.gif b/docs/assets/card.gif new file mode 100644 index 0000000..9b4ebe9 Binary files /dev/null and b/docs/assets/card.gif differ diff --git a/docs/assets/data_infrastructure_diagram.svg b/docs/assets/data_infrastructure_diagram.svg index 933e228..13050be 100644 --- a/docs/assets/data_infrastructure_diagram.svg +++ b/docs/assets/data_infrastructure_diagram.svg @@ -1,5 +1,5 @@ -dagsterelastic compute cloud (virtual machine)Dagster connected to RDSinstancecreate dataframespolarssupabaseload into stagingdatabaseverify data typesfrom APIspydanticdata quality checksperformtransformationsus-west-2read data through APIsUservirtual private cloudstore dagstermetadataRDSIaCterraformstatemanangementhashicorp cloudsupabaseload into proddatabaseus-east-2EventBridgeschedule instancedowntimeTrack RDS, EC2,and VPC \ No newline at end of file +DagsterElastic Compute CloudDagster connected to RDSinstanceCreate dataframespolarssupabaseLoad into stagingdatabaseVerify data typesfrom APIspydanticdata quality checksPerformtransformationsus-west-2Virtual Private Cloudstore dagstermetadataRDSIaCTerraformStatemanagementHashicorp cloudsupabaseLoad into proddatabaseus-east-2EventBridgeschedule instancedowntimeTrack RDS, EC2,and VPCJobStatus0 14 * * * America/Los_Angelesn8nWebhookDiscordNotificationCLIUsersStreamlitDockerWeb AppAnalyticsPostHog \ No newline at end of file diff --git a/docs/commands.md b/docs/commands.md index f2478e8..1813a73 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -29,6 +29,41 @@ Output: --- +## `card` +* Browse Pokémon TCG card data through an interactive TUI. + +The command opens a multi-step browser: + +1. Select a series. +2. Select a set from that series. +3. Browse the cards in the selected set. +4. Option to open the selected card in the image viewer with `?`. + +Card images use your terminal's graphics protocol. Image rendering support depends on the terminal. + +The following terminals are confirmed to have protocol support and render card images correctly: +* Kitty +* WezTerm +* iTerm2 +* Ghostty +* Konsole +* Rio +* Tabby +* Windows Terminal + +Basic terminal emulators may show card details without images or may not render images correctly. + +Example: +```console +poke-cli card +``` + +Output: + +![card_command](assets/card.gif) + +--- + ## `item` * Retrieve information about a specific item, including its cost, category and description. @@ -145,6 +180,19 @@ Output: ## `speed` * Calculate the speed of a Pokémon in battle. +The command opens an interactive form and asks for the following values: + +* Pokémon name +* Level: `1-100` +* Speed EVs: `0-252` +* Speed IVs: `0-31` +* Modifiers: `Choice Scarf`, `Tailwind` +* Ability: `None`, `Swift Swim`, `Chlorophyll`, `Sand Rush`, `Slush Rush`, `Unburden`, `Quick Feet`, `Surge Surfer` +* Nature multiplier: `+10%`, `0%`, `-10%` +* Speed stage: `-6` to `+6` + +The final speed is calculated with the standard stat formula and rounded down. + Example: ```console poke-cli speed @@ -182,4 +230,4 @@ poke-cli types ``` Output: -![types_command](assets/types.gif) \ No newline at end of file +![types_command](assets/types.gif) diff --git a/docs/installation.md b/docs/installation.md index f6ca301..4eb4584 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -44,11 +44,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an 1. Run the **Repository Setup** script first for the correct Linux distribution. 2. Run the corresponding **Installation Command** afterwards. -| Package Type | Distributions | Repository Setup | Installation Command | -|:------------:|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------| -| `apk` | Alpine | `sudo apk add --no-cache bash && curl -1sLf 'https://dl.cloudsmith.io/basic/digitalghost-dev/poke-cli/setup.alpine.sh' \| sudo -E bash` | `sudo apk add poke-cli=1.6.0 --update-cache` | -| `deb` | Ubuntu, Debian | `curl -1sLf 'https://dl.cloudsmith.io/public/digitalghost-dev/poke-cli/setup.deb.sh' \| sudo -E bash` | `sudo apt-get install poke-cli=1.6.0` | -| `rpm` | Fedora, CentOS, Red Hat, openSUSE | `curl -1sLf 'https://dl.cloudsmith.io/public/digitalghost-dev/poke-cli/setup.rpm.sh' \| sudo -E bash` | `sudo yum install poke-cli-1.6.0-1` | +| Package Type | Distributions | Repository Setup | Installation Command | +|:------------:|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------| +| `apk` | Alpine | `sudo apk add --no-cache bash && curl -1sLf 'https://dl.cloudsmith.io/basic/digitalghost-dev/poke-cli/setup.alpine.sh' \| sudo -E bash` | `sudo apk add poke-cli --update-cache` | +| `deb` | Ubuntu, Debian | `curl -1sLf 'https://dl.cloudsmith.io/public/digitalghost-dev/poke-cli/setup.deb.sh' \| sudo -E bash` | `sudo apt-get install poke-cli` | +| `rpm` | Fedora, CentOS, Red Hat, openSUSE | `curl -1sLf 'https://dl.cloudsmith.io/public/digitalghost-dev/poke-cli/setup.rpm.sh' \| sudo -E bash` | `sudo yum install poke-cli` | ### Docker Image @@ -63,15 +63,30 @@ 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.6.0 [subcommand] flag] + docker run --rm -it digitalghostdev/poke-cli:v1.10.1 [subcommand] [flag] ``` * Enter the container and use its shell: ```console - docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.6.0 -c "cd /app && exec sh" + docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.1 -c "cd /app && exec sh" # placed into the /app directory, run the program with './poke-cli' # example: ./poke-cli ability swift-swim ``` +!!! note + + 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.1 card + + # WezTerm, iTerm2, Ghostty, Konsole, Rio, Tabby + docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.1 card + + # Windows Terminal (Sixel) + docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.1 card + ``` + If your terminal is not listed above, image rendering is not supported inside Docker. + ### Binary 1. Head to the [releases](https://github.com/digitalghost-dev/poke-cli/releases) page of the project. @@ -81,16 +96,13 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an 5. Either change directories into the extracted folder or move the binary to a chosen directory. 6. Run the tool! -> [!IMPORTANT] -> For macOS, you may have to allow the executable to run as it is not signed. Head to System Settings > Privacy & Security > scroll down and allow executable to run. - -
+!!! warning -View Image of Settings + For macOS, you may have to allow the executable to run as it is not signed. Head to System Settings > Privacy & Security > scroll down and allow executable to run. -![settings](https://poke-cli-s3-bucket.s3.us-west-2.amazonaws.com/macos_privacy_settings.png) +??? example "View Image of Settings" -
+ ![settings](https://dc8hq8aq7pr04.cloudfront.net/macos_privacy_settings.png) #### Example usage diff --git a/flags/pokemonflagset.go b/flags/pokemonflagset.go index 1f2047d..2748d0f 100644 --- a/flags/pokemonflagset.go +++ b/flags/pokemonflagset.go @@ -78,7 +78,7 @@ func SetupPokemonFlagSet() *PokemonFlags { pf.ShortStats = pf.FlagSet.Bool("s", false, "Print the Pokémon's base stats") pf.Types = pf.FlagSet.Bool("types", false, "Print the Pokémon's typing") - pf.ShortTypes = pf.FlagSet.Bool("t", false, "Prints the Pokémon's typing") + pf.ShortTypes = pf.FlagSet.Bool("t", false, "Print the Pokémon's typing") hintMessage := styling.StyleItalic.Render("options: [sm, md, lg]") @@ -89,9 +89,9 @@ func SetupPokemonFlagSet() *PokemonFlags { fmt.Sprintf("\n\t%-30s %s", "-d, --defense", "Prints the Pokémon's type defenses."), fmt.Sprintf("\n\t%-30s %s", "-i=xx, --image=xx", "Prints out the Pokémon's default sprite."), fmt.Sprintf("\n\t%5s%-15s", "", hintMessage), - fmt.Sprintf("\n\t%-30s %s", "-m, --moves", "Prints the Pokemon's learnable moves."), + fmt.Sprintf("\n\t%-30s %s", "-m, --moves", "Prints the Pokémon's learnable moves."), fmt.Sprintf("\n\t%-30s %s", "-s, --stats", "Prints the Pokémon's base stats."), - fmt.Sprintf("\n\t%-30s %s", "-t, --types", "Prints the Pokémon's typing."), + fmt.Sprintf("\n\t%-30s %s", "-t, --types", styling.ErrorColor.Render("Deprecated. Typing is included by default.")), fmt.Sprintf("\n\t%-30s %s", "-h, --help", "Prints the help menu."), ) fmt.Println(helpMessage) @@ -323,6 +323,17 @@ func DefenseFlag(w io.Writer, endpoint string, pokemonName string) error { } func ImageFlag(w io.Writer, endpoint string, pokemonName string, size string) error { + sizeMap := map[string][2]int{ + "lg": {120, 120}, + "md": {90, 90}, + "sm": {55, 55}, + } + + dimensions, exists := sizeMap[strings.ToLower(size)] + if !exists { + return fmt.Errorf("%s", cmdutils.FormatError("Invalid image size.\nValid sizes are: lg, md, sm")) + } + pokemonStruct, _, err := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL) if err != nil { return err @@ -391,19 +402,6 @@ func ImageFlag(w io.Writer, endpoint string, pokemonName string, size string) er return err } - // Define size map - sizeMap := map[string][2]int{ - "lg": {120, 120}, - "md": {90, 90}, - "sm": {55, 55}, - } - - // Validate size - dimensions, exists := sizeMap[strings.ToLower(size)] - if !exists { - return fmt.Errorf("%s", cmdutils.FormatError("Invalid image size.\nValid sizes are: lg, md, sm")) - } - imgStr := ToString(dimensions[0], dimensions[1], img) _, err = fmt.Fprint(w, imgStr) if err != nil { diff --git a/gitleaks.toml b/gitleaks.toml index c25f235..9639b74 100644 --- a/gitleaks.toml +++ b/gitleaks.toml @@ -6,5 +6,6 @@ title = "Custom Gitleaks configuration" [allowlist] regexTarget = "match" regexes = [ - '''sb_publishable_[A-Za-z0-9_-]+''' + '''sb_publishable_[A-Za-z0-9_-]+''', + '''phc_[A-Za-z0-9]+''' ] \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 2f856f1..7396ed8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,7 +3,7 @@ site_name: poke-cli theme: icon: admonition: - note: octicons/tag-16 + note: octicons/pencil-16 abstract: octicons/checklist-16 info: octicons/info-16 tip: octicons/squirrel-16 diff --git a/nfpm.yaml b/nfpm.yaml index a8715ad..03cf344 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -1,7 +1,7 @@ name: "poke-cli" arch: "arm64" platform: "linux" -version: "v1.10.0" +version: "v1.10.1" section: "default" version_schema: semver maintainer: "Christian S" diff --git a/testdata/main_latest_flag.golden b/testdata/main_latest_flag.golden index adb69f9..d2e3224 100644 --- a/testdata/main_latest_flag.golden +++ b/testdata/main_latest_flag.golden @@ -2,6 +2,6 @@ ┃ ┃ ┃ Latest available release ┃ ┃ on GitHub: ┃ -┃ • v1.9.4 ┃ +┃ • v1.10.0 ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/testdata/pokemon_help.golden b/testdata/pokemon_help.golden index 7fcce86..48a044b 100644 --- a/testdata/pokemon_help.golden +++ b/testdata/pokemon_help.golden @@ -1,16 +1,17 @@ -╭────────────────────────────────────────────────────────────────────────────────────╮ -│Get details about a specific Pokémon. │ -│ │ -│ USAGE: │ -│ poke-cli pokemon [flag] │ -│ Use a hyphen when typing a name with a space. │ -│ │ -│ FLAGS: │ -│ -h, --help Prints the help menu. │ -│ -a, --abilities Prints the Pokémon's abilities. │ -│ -i=xx, --image=xx Prints out the Pokémon's default sprite. │ -│ options: [sm, md, lg] │ -│ -m, --moves Prints the Pokémon's learnable moves. │ -│ -s, --stats Prints the Pokémon's base stats. │ -│ -t, --types Deprecated. Types are included with each Pokémon.│ -╰────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭─────────────────────────────────────────────────────────────────────────────╮ +│Get details about a specific Pokémon. │ +│ │ +│ USAGE: │ +│ poke-cli pokemon [flag] │ +│ Use a hyphen when typing a name with a space. │ +│ │ +│ FLAGS: │ +│ -h, --help Prints the help menu. │ +│ -a, --abilities Prints the Pokémon's abilities. │ +│ -d, --defense Prints the Pokémon's type defenses. │ +│ -i=xx, --image=xx Prints out the Pokémon's default sprite. │ +│ options: [sm, md, lg] │ +│ -m, --moves Prints the Pokémon's learnable moves. │ +│ -s, --stats Prints the Pokémon's base stats. │ +│ -t, --types Deprecated. Typing is included by default.│ +╰─────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/testdata/pokemon_image_flag_non-valid_size.golden b/testdata/pokemon_image_flag_non-valid_size.golden index 679a62e..2988555 100644 --- a/testdata/pokemon_image_flag_non-valid_size.golden +++ b/testdata/pokemon_image_flag_non-valid_size.golden @@ -13,8 +13,6 @@ in the rescues of drowning people. • Gender Rate: 50% F • Egg Cycles: 20 • Effort Values: 2 Spd -───── -Image ╭───────────────────────────╮ │✖ Error! │ │Invalid image size. │ diff --git a/web/Dockerfile b/web/Dockerfile index 0ca5855..5421c84 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -10,10 +10,10 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv COPY pyproject.toml uv.lock ./ -RUN uv sync --frozen --no-dev --no-cache +RUN uv sync --frozen --no-dev --no-cache --group analytics COPY --chown=streamlit_user:streamlit_group .streamlit .streamlit -COPY --chown=streamlit_user:streamlit_group app.py . +COPY --chown=streamlit_user:streamlit_group app.py analytics.py ./ RUN chown -R streamlit_user:streamlit_group /app diff --git a/web/analytics.py b/web/analytics.py new file mode 100644 index 0000000..6646b0f --- /dev/null +++ b/web/analytics.py @@ -0,0 +1,54 @@ +import uuid + +import streamlit as st +from posthog import Posthog + +POSTHOG_API_KEY = "phc_qLvoCFJ5U9qgMS4p6LuJPgc3ZcrCRZYBNHLueHE9MU4C" +POSTHOG_HOST = "https://us.i.posthog.com" + + +@st.cache_resource +def init_posthog() -> Posthog: + return Posthog( + POSTHOG_API_KEY, + host=POSTHOG_HOST, + disable_geoip=False, + ) + + +def _truncate_ip(ip: str | None) -> str | None: + """Zero out the last octet (IPv4) or last 80 bits (IPv6) so the stored + IP can still resolve to a country but cannot uniquely identify a visitor.""" + if not ip: + return None + if ":" in ip: + parts = ip.split(":")[:3] + return ":".join(parts) + "::" + parts = ip.split(".") + if len(parts) == 4: + return ".".join(parts[:3] + ["0"]) + return None + + +def track_visit() -> None: + if st.session_state.get("ph_visited"): + return + + posthog = init_posthog() + distinct_id = st.session_state.setdefault("ph_distinct_id", str(uuid.uuid4())) + forwarded_for = st.context.headers.get("X-Forwarded-For", "") + raw_ip = forwarded_for.split(",")[0].strip() if forwarded_for else None + ip = _truncate_ip(raw_ip) + + # Events are anonymous: no persistent person profile is created, and the + # IP is truncated before being sent so PostHog can resolve country but + # cannot uniquely identify or track users across visits. + properties: dict = { + "$current_url": str(st.context.url), + "$process_person_profile": False, + } + if ip: + properties["$ip"] = ip + + posthog.capture("$pageview", distinct_id=distinct_id, properties=properties) + st.session_state["ph_visited"] = True diff --git a/web/app.py b/web/app.py index 1276157..d06f98f 100644 --- a/web/app.py +++ b/web/app.py @@ -6,6 +6,8 @@ from supabase import Client, create_client +from analytics import track_visit + @st.cache_resource def init_connection() -> Client: @@ -29,19 +31,16 @@ def unique_locations() -> list: result = ( supabase.table("standings") .select("location, text_date") + .eq("rank", 1) .order("start_date") .execute() ) - return list( - dict.fromkeys( - (row["location"], row["text_date"]) for row in result.data - ) # pyrefly: ignore[bad-index, unsupported-operation] - ) + return [(row["location"], row["text_date"]) for row in result.data] @st.cache_data(ttl=86400) def tournament_locations() -> list[dict[str, str]]: tournaments = supabase.table("standings").select( - "location, tournament_latitude, tournament_longitude, type, text_date, player_quantity" + "location, tournament_latitude, tournament_longitude, type, text_date, player_quantity, iso_code" ).eq("rank", 1).order("start_date").execute().data @@ -84,7 +83,33 @@ def tournament_info(tourney_filter: str): if logo: st.image(logo, width=100) +class SeasonKPIs: + def __init__(self, tournaments: list[dict[str, str]]): + self.tournaments = tournaments + + def render(self) -> None: + total_tournaments = len(self.tournaments) + total_participants = sum( + int(t["player_quantity"]) for t in self.tournaments if t.get("player_quantity") + ) + countries_hosted = len({t["iso_code"] for t in self.tournaments if t.get("iso_code")}) + latest_event = self.tournaments[-1]["location"] if self.tournaments else "—" + + with st.container(horizontal=True): + st.metric("Tournaments Tracked", f"{total_tournaments:,}", border=True) + st.metric("Total Participants", f"{total_participants:,}", border=True) + st.metric("Countries Hosted In", f"{countries_hosted:,}", border=True) + st.metric("Latest Event", latest_event, border=True) + + class SeasonTournamentLocations: + TYPE_COLORS = { + "International": [220, 50, 50, 200], + "Regional": [255, 204, 0, 200], + "Special Event": [50, 100, 220, 200], + "World": [50, 200, 100, 200], + } + def __init__(self, tournaments: list[dict[str,str]]): self.tournaments = tournaments @@ -93,15 +118,8 @@ def _build_map(self) -> pydeck.Deck: tournaments = self.tournaments - type_colors = { - "International": [220, 50, 50, 200], - "Regional": [255, 204, 0, 200], - "Special Event": [50, 100, 220, 200], - "World": [50, 200, 100, 200], - } - for t in tournaments: - t["color"] = type_colors.get( + t["color"] = self.TYPE_COLORS.get( t["type"], [200, 200, 200, 200] ) # pyrefly: ignore[bad-index, unsupported-operation, no-matching-overload] @@ -127,8 +145,20 @@ def _build_map(self) -> pydeck.Deck: return fig + def _render_legend(self) -> None: + items = [] + for t_type, rgba in self.TYPE_COLORS.items(): + r, g, b, _ = rgba + items.append( + f'' + f'' + f'{t_type}' + ) + st.markdown("".join(items), unsafe_allow_html=True) + def render(self): self._build_map() + self._render_legend() class DeckStats: @@ -404,12 +434,22 @@ def render(self) -> None: def main(): + track_visit() + if not st.session_state.get("privacy_toast_shown"): + st.toast( + "Anonymous analytics only — no cookies, no full IPs, no personal profiles.", + icon="🔒", + ) + st.session_state["privacy_toast_shown"] = True st.header("Pokémon TCG Tournament Data") + st.subheader("Browse Pokémon TCG tournament results by season, location, event type, and standings.") overview_tab, regionals_tab = st.tabs(["Season Overview", "Tournaments"]) with overview_tab: - SeasonTournamentLocations(tournament_locations()).render() + tournaments = tournament_locations() + SeasonKPIs(tournaments).render() + SeasonTournamentLocations(tournaments).render() with regionals_tab: tourney_filter = header() diff --git a/web/app_test.py b/web/app_test.py index 54d44fa..98ecbab 100644 --- a/web/app_test.py +++ b/web/app_test.py @@ -33,8 +33,8 @@ def test_selectbox_label(): def test_tournament_info(): assert not at.exception - assert "•" in at.markdown[0].value - assert "flagcdn.com" in at.markdown[0].value + tournament_md = next(m for m in at.markdown if "flagcdn.com" in m.value) + assert "•" in tournament_md.value def test_dataframe_renders(): @@ -67,12 +67,10 @@ def test_dataframe_columns(): def test_metrics(): - assert at.metric[0].label == "Total Players" - assert at.metric[1].label == "Winner" - assert at.metric[2].label == "Winning Deck" - assert at.metric[0].value is not None - assert at.metric[1].value is not None - assert at.metric[2].value is not None + metrics_by_label = {m.label: m for m in at.metric} + for label in ("Total Players", "Winner", "Winning Deck"): + assert label in metrics_by_label + assert metrics_by_label[label].value is not None def test_dataframe_sorted_by_rank(): diff --git a/web/pyproject.toml b/web/pyproject.toml index 5f4b931..9fcc485 100644 --- a/web/pyproject.toml +++ b/web/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "web" -version = "v1.10.0" +version = "v1.10.1" description = "Streamlit dashboard for browsing and visualizing Pokémon TCG tournament standings and results." readme = "README.md" requires-python = ">=3.12" @@ -13,9 +13,12 @@ dependencies = [ ] [dependency-groups] +analytics = [ + "posthog==7.14.0", +] dev = [ - "pytest>=9.0.2", - "pytest-cov>=7.0.0", + "pytest==9.0.2", + "pytest-cov==7.0.0", ] [tool.pytest.ini_options] diff --git a/web/uv.lock b/web/uv.lock index 6c29132..f20aae2 100644 --- a/web/uv.lock +++ b/web/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -49,6 +49,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -360,6 +369,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "fsspec" version = "2026.2.0" @@ -1032,6 +1050,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/47/43deadb113d8730e59d5045eb0968eb2ca8ccbad7506bd4fc4a18294e114/postgrest-2.28.0-py3-none-any.whl", hash = "sha256:7bca2f24dd1a1bf8a3d586c7482aba6cd41662da6733045fad585b63b7f7df75", size = 22008, upload-time = "2026-02-10T13:16:59.307Z" }, ] +[[package]] +name = "posthog" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/0f/0e6578feaf0d4e670bc517b6da09ec147a65421c44e0cd687eba12f08743/posthog-7.14.0.tar.gz", hash = "sha256:3be5e513f07e4ee5119f98b0458cb640739b49cef7c96c3e18b1d65076b18239", size = 205083, upload-time = "2026-05-01T20:41:37.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/c2/2dc3e08e481f45c0215da4325ccc9b5f368dcee504779d2f169ab567c766/posthog-7.14.0-py3-none-any.whl", hash = "sha256:76db6e3158e2c11ec9bbcf32a673efec4acc8078965d92e2d3055555220ee546", size = 240187, upload-time = "2026-05-01T20:41:36.022Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -1800,7 +1833,7 @@ wheels = [ [[package]] name = "web" -version = "1.9.0" +version = "1.10.1" source = { virtual = "." } dependencies = [ { name = "plotly" }, @@ -1811,6 +1844,9 @@ dependencies = [ ] [package.dev-dependencies] +analytics = [ + { name = "posthog" }, +] dev = [ { name = "pytest" }, { name = "pytest-cov" }, @@ -1826,9 +1862,10 @@ requires-dist = [ ] [package.metadata.requires-dev] +analytics = [{ name = "posthog", specifier = "==7.14.0" }] dev = [ - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-cov", specifier = "==7.0.0" }, ] [[package]]